From 8c49c1494b7dc0e0f95f8d9608508c6b72227c9e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:03:52 +1000 Subject: [PATCH 001/616] feat(deployment): add read-only assessment artifact --- semapact/deployment/models.py | 90 +++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index a32cefe8..f4063110 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -22,6 +22,9 @@ SEMAPACT_DEPLOYMENT_PREVIEW_NAMESPACE = uuid.UUID( "0ee613b9-a9ef-4a95-ac87-25d6c706b16c" ) +SEMAPACT_DEPLOYMENT_ASSESSMENT_NAMESPACE = uuid.UUID( + "7dfe1ca4-bd3e-4a8c-b89f-52217c326f52" +) class DeploymentModel(BaseModel): @@ -216,6 +219,48 @@ def _validate_identity(self) -> DeploymentPreview: return self +class DeploymentAssessment(DeploymentModel): + """Read-only runtime assessment for one candidate contract revision. + + This artifact intentionally carries no AppliedContractRelease, DeploymentPlan, + or authorization identity and therefore cannot be used as execution authority. + """ + + deployment_assessment_id: str + contract_id: str + candidate_revision_ref: str + candidate_version: str + target: DeploymentTarget + source_identifier: str + observation_fingerprint: str + operations: tuple[NativeOperation, ...] + assessment_version: Literal["1"] = "1" + + @field_validator( + "deployment_assessment_id", + "contract_id", + "candidate_revision_ref", + "candidate_version", + "source_identifier", + "observation_fingerprint", + ) + @classmethod + def _require_assessment_text(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("assessment fields must not be empty") + return cleaned + + @model_validator(mode="after") + def _validate_identity(self) -> "DeploymentAssessment": + if self.source_identifier != self.target.source_reference: + raise ValueError( + "DeploymentAssessment source identifier must match target source reference" + ) + validate_deployment_assessment_identity(self) + return self + + class DeploymentAuthorization(DeploymentModel): """Authorization bound to one exact DeploymentPlan and applied release.""" @@ -270,6 +315,32 @@ def compute_deployment_plan_id( ) +def compute_deployment_assessment_id( + *, + contract_id: str, + candidate_revision_ref: str, + candidate_version: str, + target: DeploymentTarget, + source_identifier: str, + observation_fingerprint: str, + operations: Sequence[NativeOperation], + assessment_version: str = "1", +) -> str: + return deterministic_uuid5( + SEMAPACT_DEPLOYMENT_ASSESSMENT_NAMESPACE, + { + "contract_id": contract_id, + "candidate_revision_ref": candidate_revision_ref, + "candidate_version": candidate_version, + "target": target.model_dump(mode="json"), + "source_identifier": source_identifier, + "observation_fingerprint": observation_fingerprint, + "operations": [operation.model_dump(mode="json") for operation in operations], + "assessment_version": assessment_version, + }, + ) + + def compute_deployment_authorization_id( *, contract_ops_authorization_id: str, @@ -327,6 +398,25 @@ def validate_deployment_plan_identity(plan: DeploymentPlan) -> None: raise ValueError("DeploymentPlan deterministic identity does not match content") +def validate_deployment_assessment_identity( + assessment: DeploymentAssessment, +) -> None: + expected = compute_deployment_assessment_id( + contract_id=assessment.contract_id, + candidate_revision_ref=assessment.candidate_revision_ref, + candidate_version=assessment.candidate_version, + target=assessment.target, + source_identifier=assessment.source_identifier, + observation_fingerprint=assessment.observation_fingerprint, + operations=assessment.operations, + assessment_version=assessment.assessment_version, + ) + if expected != assessment.deployment_assessment_id: + raise ValueError( + "DeploymentAssessment deterministic identity does not match content" + ) + + def validate_deployment_authorization_identity( authorization: DeploymentAuthorization, ) -> None: From d6c5863329779bb4aef633d362d97cac346bdc48 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:04:04 +1000 Subject: [PATCH 002/616] refactor(deployment): share desired action projection --- semapact/deployment/planner.py | 59 ++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/semapact/deployment/planner.py b/semapact/deployment/planner.py index 10894c1f..47520582 100644 --- a/semapact/deployment/planner.py +++ b/semapact/deployment/planner.py @@ -2,7 +2,7 @@ from __future__ import annotations -from open_data_contract_standard.model import SchemaObject +from open_data_contract_standard.model import OpenDataContractStandard, SchemaObject from semapact.contractops.execution_models import AppliedContractRelease from semapact.contractops.integrity import validate_applied_release_identity @@ -38,26 +38,7 @@ def build_deployment_plan( validate_applied_release_identity(release) contract = release.to_contract() - asset_specs = runtime_asset_specs_from_contract(contract) - specs_by_asset = {spec.governed_asset: spec for spec in asset_specs} - - actions: list[DeploymentAction] = [] - for schema in contract.schema_ or []: - raw_name = getattr(schema, "name", None) - if raw_name is None: - raise RuntimeError("Governed schema identity unexpectedly missing") - governed_asset = normalize_identity_name(str(raw_name), "Schema") - spec = specs_by_asset[governed_asset] - actions.append( - DeploymentAction( - kind=DeploymentActionKind.ENSURE_ASSET_STATE, - governed_asset=governed_asset, - physical_name=spec.physical_name, - desired_state_json=_canonical_schema_json(schema), - ) - ) - - ordered_actions = tuple(sorted(actions, key=lambda action: action.governed_asset)) + ordered_actions = build_deployment_actions(contract) deployment_plan_id = compute_deployment_plan_id( applied_release_id=release.applied_release_id, contract_id=release.contract_id, @@ -80,6 +61,42 @@ def build_deployment_plan( ) +def build_deployment_actions( + contract: OpenDataContractStandard, +) -> tuple[DeploymentAction, ...]: + """Project candidate/released ODCS state into provider-neutral desired actions. + + Actions are desired-state facts only. They are not executable authority until + they are bound into an exact DeploymentPlan derived from an AppliedContractRelease. + """ + if not isinstance(contract, OpenDataContractStandard): + raise TypeError( + "contract must be OpenDataContractStandard, " + f"got {type(contract).__name__}" + ) + + asset_specs = runtime_asset_specs_from_contract(contract) + specs_by_asset = {spec.governed_asset: spec for spec in asset_specs} + + actions: list[DeploymentAction] = [] + for schema in contract.schema_ or []: + raw_name = getattr(schema, "name", None) + if raw_name is None: + raise RuntimeError("Governed schema identity unexpectedly missing") + governed_asset = normalize_identity_name(str(raw_name), "Schema") + spec = specs_by_asset[governed_asset] + actions.append( + DeploymentAction( + kind=DeploymentActionKind.ENSURE_ASSET_STATE, + governed_asset=governed_asset, + physical_name=spec.physical_name, + desired_state_json=_canonical_schema_json(schema), + ) + ) + + return tuple(sorted(actions, key=lambda action: action.governed_asset)) + + def _canonical_schema_json(schema: SchemaObject) -> str: payload = schema.model_dump(mode="json", by_alias=True, exclude_none=True) return canonical_compact_json(payload) From 47bb3719b8c5539f6fb0d06a8e245301b20bb1a5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:04:21 +1000 Subject: [PATCH 003/616] feat(deployment): expose candidate assessment entrypoint --- semapact/deployment/adapters.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/semapact/deployment/adapters.py b/semapact/deployment/adapters.py index 5bfbe689..974ba0cb 100644 --- a/semapact/deployment/adapters.py +++ b/semapact/deployment/adapters.py @@ -4,10 +4,14 @@ from abc import ABC, abstractmethod +from open_data_contract_standard.model import OpenDataContractStandard + from semapact.deployment.models import ( + DeploymentAssessment, DeploymentAuthorization, DeploymentPlan, DeploymentPreview, + DeploymentTarget, ) from semapact.reconciliation import ReconciliationResult @@ -17,6 +21,18 @@ class DeploymentAdapter(ABC): key: str + def assess( + self, + contract: OpenDataContractStandard, + *, + candidate_revision_ref: str, + target: DeploymentTarget, + ) -> DeploymentAssessment: + """Observe runtime and assess one candidate without creating execution authority.""" + raise NotImplementedError( + f"{type(self).__name__} does not support candidate deployment assessment" + ) + @abstractmethod def validate(self, plan: DeploymentPlan) -> None: """Validate one exact DeploymentPlan.""" From cb2da8fac64ccb260a79a7d4c6eb6dc7cde040af Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:04:50 +1000 Subject: [PATCH 004/616] feat(deployment): assess candidate against fresh runtime --- semapact/deployment/orchestrator.py | 197 ++++++++++++++++++++++------ 1 file changed, 158 insertions(+), 39 deletions(-) diff --git a/semapact/deployment/orchestrator.py b/semapact/deployment/orchestrator.py index 6e95b994..529cc623 100644 --- a/semapact/deployment/orchestrator.py +++ b/semapact/deployment/orchestrator.py @@ -2,22 +2,27 @@ from __future__ import annotations -from open_data_contract_standard.model import SchemaObject +from open_data_contract_standard.model import OpenDataContractStandard, SchemaObject from semapact.deployment.adapters import DeploymentAdapter from semapact.deployment.compilers import TransitionCompiler from semapact.deployment.models import ( + DeploymentAction, DeploymentActionKind, + DeploymentAssessment, DeploymentAuthorization, DeploymentPlan, DeploymentPreview, + DeploymentTarget, NativeOperation, NativeOperationKind, + compute_deployment_assessment_id, compute_deployment_preview_id, validate_deployment_authorization_identity, validate_deployment_plan_identity, validate_deployment_preview_identity, ) +from semapact.deployment.planner import build_deployment_actions from semapact.deployment.providers import NativeOperationExecutor from semapact.deployment.schema_transitions import SchemaTransitionPlanner from semapact.deployment.verification import verify_deployment_convergence @@ -69,6 +74,71 @@ def __init__( def key(self) -> str: return self._runtime_provider.key + def assess( + self, + contract: OpenDataContractStandard, + *, + candidate_revision_ref: str, + target: DeploymentTarget, + ) -> DeploymentAssessment: + """Assess candidate desired state against fresh runtime without authority.""" + if not isinstance(contract, OpenDataContractStandard): + raise TypeError( + "contract must be OpenDataContractStandard, " + f"got {type(contract).__name__}" + ) + if not isinstance(target, DeploymentTarget): + raise TypeError( + f"target must be DeploymentTarget, got {type(target).__name__}" + ) + + contract_id = str(contract.id or "").strip() + candidate_version = str(contract.version or "").strip() + revision_ref = str(candidate_revision_ref).strip() + if not contract_id: + raise ValidationError("Candidate contract id is required for deployment assessment") + if not candidate_version: + raise ValidationError( + "Candidate contract version is required for deployment assessment" + ) + if not revision_ref: + raise ValidationError( + "candidate_revision_ref is required for deployment assessment" + ) + + actions = build_deployment_actions(contract) + desired_by_action = self._validate_and_map_actions(target, actions) + observed_state, bindings = self._observe_scope(target, actions) + operations = self._operations_for_scope( + target, + actions, + observed_state, + bindings=bindings, + desired_by_action=desired_by_action, + ) + if observed_state.fingerprint is None: + raise ValidationError("Runtime observation fingerprint is required") + + assessment_id = compute_deployment_assessment_id( + contract_id=contract_id, + candidate_revision_ref=revision_ref, + candidate_version=candidate_version, + target=target, + source_identifier=observed_state.source_identifier, + observation_fingerprint=observed_state.fingerprint, + operations=operations, + ) + return DeploymentAssessment( + deployment_assessment_id=assessment_id, + contract_id=contract_id, + candidate_revision_ref=revision_ref, + candidate_version=candidate_version, + target=target, + source_identifier=observed_state.source_identifier, + observation_fingerprint=observed_state.fingerprint, + operations=operations, + ) + def validate(self, plan: DeploymentPlan) -> None: """Validate one exact plan without runtime observation side effects.""" self._validate_and_map_plan(plan) @@ -170,15 +240,54 @@ def _preview_from_observation( ) -> DeploymentPreview: if desired_by_action is None: desired_by_action = self._validate_and_map_plan(plan) - self._validate_observation(plan, observed_state, bindings=bindings) + operations = self._operations_for_scope( + plan.target, + plan.actions, + observed_state, + bindings=bindings, + desired_by_action=desired_by_action, + ) + + if observed_state.fingerprint is None: + raise ValidationError("Runtime observation fingerprint is required") + return DeploymentPreview( + deployment_preview_id=compute_deployment_preview_id( + deployment_plan_id=plan.deployment_plan_id, + platform=self.key, + runtime_target=plan.target.runtime_target, + source_identifier=observed_state.source_identifier, + observation_fingerprint=observed_state.fingerprint, + operations=operations, + ), + deployment_plan_id=plan.deployment_plan_id, + platform=self.key, + runtime_target=plan.target.runtime_target, + source_identifier=observed_state.source_identifier, + observation_fingerprint=observed_state.fingerprint, + operations=operations, + ) + def _operations_for_scope( + self, + target: DeploymentTarget, + actions: tuple[DeploymentAction, ...], + observed_state: ObservedPlatformState, + *, + bindings: tuple[RuntimeAssetBinding, ...], + desired_by_action: dict[str, SchemaAssetState], + ) -> tuple[NativeOperation, ...]: + self._validate_observation_for_scope( + target, + observed_state, + bindings=bindings, + ) observed_by_asset = { asset.identity.asset.casefold(): asset for asset in observed_state.assets } operations: list[NativeOperation] = [] - for action in plan.actions: + for action in actions: desired_asset = desired_by_action[action.governed_asset] observed = observed_by_asset.get(action.physical_name.casefold()) observed_assets = ( @@ -191,7 +300,6 @@ def _preview_from_observation( ), ) ) - comparison = compare_schema_snapshots( SchemaSnapshot(assets=(desired_asset,)), SchemaSnapshot(assets=observed_assets), @@ -205,46 +313,32 @@ def _preview_from_observation( ) operations.append( self._transition_compiler.compile( - runtime_target=plan.target.runtime_target, + runtime_target=target.runtime_target, transition=transition, ) ) - - if observed_state.fingerprint is None: - raise ValidationError("Runtime observation fingerprint is required") - ordered = tuple(operations) - return DeploymentPreview( - deployment_preview_id=compute_deployment_preview_id( - deployment_plan_id=plan.deployment_plan_id, - platform=self.key, - runtime_target=plan.target.runtime_target, - source_identifier=observed_state.source_identifier, - observation_fingerprint=observed_state.fingerprint, - operations=ordered, - ), - deployment_plan_id=plan.deployment_plan_id, - platform=self.key, - runtime_target=plan.target.runtime_target, - source_identifier=observed_state.source_identifier, - observation_fingerprint=observed_state.fingerprint, - operations=ordered, - ) + return tuple(operations) def _validate_and_map_plan( self, plan: DeploymentPlan, ) -> dict[str, SchemaAssetState]: validate_deployment_plan_identity(plan) - if plan.target.platform.casefold() != self.key.casefold(): + return self._validate_and_map_actions(plan.target, plan.actions) + + def _validate_and_map_actions( + self, + target: DeploymentTarget, + actions: tuple[DeploymentAction, ...], + ) -> dict[str, SchemaAssetState]: + if target.platform.casefold() != self.key.casefold(): raise ValidationError( - f"Runtime provider '{self.key}' cannot deploy " - f"'{plan.target.platform}'" + f"Runtime provider '{self.key}' cannot deploy '{target.platform}'" ) physical_assets: set[str] = set() desired_by_action: dict[str, SchemaAssetState] = {} - - for action in plan.actions: + for action in actions: if action.kind is not DeploymentActionKind.ENSURE_ASSET_STATE: raise ValidationError( f"Unsupported deployment action kind: {action.kind.value}" @@ -269,30 +363,35 @@ def _validate_and_map_plan( ) desired_by_action[action.governed_asset] = mapped - # Binding resolution is pure provider-local identity resolution. Calling it - # here validates runtime-target syntax and exact plan bindings without I/O. - self._resolve_plan_bindings(plan) + self._resolve_bindings(target, actions) return desired_by_action def _resolve_plan_bindings( self, plan: DeploymentPlan, + ) -> tuple[RuntimeAssetBinding, ...]: + return self._resolve_bindings(plan.target, plan.actions) + + def _resolve_bindings( + self, + target: DeploymentTarget, + actions: tuple[DeploymentAction, ...], ) -> tuple[RuntimeAssetBinding, ...]: assets = tuple( RuntimeAssetSpec( governed_asset=action.governed_asset, physical_name=action.physical_name, ) - for action in plan.actions + for action in actions ) bindings = self._runtime_provider.resolve_bindings( - runtime_target=plan.target.runtime_target, + runtime_target=target.runtime_target, assets=assets, ) expected = { action.governed_asset: action.physical_name.casefold() - for action in plan.actions + for action in actions } if len(bindings) != len(expected): raise ValidationError( @@ -337,6 +436,19 @@ def _validate_observation( observed_state: ObservedPlatformState, *, bindings: tuple[RuntimeAssetBinding, ...], + ) -> None: + self._validate_observation_for_scope( + plan.target, + observed_state, + bindings=bindings, + ) + + def _validate_observation_for_scope( + self, + target: DeploymentTarget, + observed_state: ObservedPlatformState, + *, + bindings: tuple[RuntimeAssetBinding, ...], ) -> None: if observed_state.platform.casefold() != self.key.casefold(): raise ValidationError( @@ -346,9 +458,9 @@ def _validate_observation( raise ValidationError( "Runtime observation source_identifier is required" ) - if observed_state.source_identifier != plan.target.source_reference: + if observed_state.source_identifier != target.source_reference: raise ValidationError( - "Runtime observation source does not match DeploymentPlan " + "Runtime observation source does not match deployment target " "source reference" ) if observed_state.fingerprint is None: @@ -379,7 +491,14 @@ def _observe_plan_scope( self, plan: DeploymentPlan, ) -> tuple[ObservedPlatformState, tuple[RuntimeAssetBinding, ...]]: - bindings = self._resolve_plan_bindings(plan) + return self._observe_scope(plan.target, plan.actions) + + def _observe_scope( + self, + target: DeploymentTarget, + actions: tuple[DeploymentAction, ...], + ) -> tuple[ObservedPlatformState, tuple[RuntimeAssetBinding, ...]]: + bindings = self._resolve_bindings(target, actions) return ( self._runtime_provider.observe(bindings=bindings), bindings, From 81ce5db5e67056c2164d4f536ef7370b16156137 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:05:06 +1000 Subject: [PATCH 005/616] feat(application): expose read-only deployment assessment --- semapact/application/services/deployment.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/semapact/application/services/deployment.py b/semapact/application/services/deployment.py index fa5d62c9..6b4de767 100644 --- a/semapact/application/services/deployment.py +++ b/semapact/application/services/deployment.py @@ -2,9 +2,12 @@ from __future__ import annotations +from open_data_contract_standard.model import OpenDataContractStandard + from semapact.contractops import AppliedContractRelease from semapact.deployment import ( DeploymentAdapter, + DeploymentAssessment, DeploymentAuthorization, DeploymentPlan, DeploymentPreview, @@ -18,6 +21,21 @@ class DeploymentService: """Application boundary over the generic deployment lifecycle.""" + def assess( + self, + contract: OpenDataContractStandard, + *, + candidate_revision_ref: str, + target: DeploymentTarget, + adapter: DeploymentAdapter, + ) -> DeploymentAssessment: + _validate_component_key(adapter.key, target.platform, "deployment adapter") + return adapter.assess( + contract, + candidate_revision_ref=candidate_revision_ref, + target=target, + ) + def plan( self, release: AppliedContractRelease, From 7971565a10b0e469372e0c993dcc8e23637a8085 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:05:15 +1000 Subject: [PATCH 006/616] feat(application): model contract-first deployment assessment --- .../application/models/deployment_workflow.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 semapact/application/models/deployment_workflow.py diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py new file mode 100644 index 00000000..73f25bd5 --- /dev/null +++ b/semapact/application/models/deployment_workflow.py @@ -0,0 +1,16 @@ +"""Application result models for contract-first deployment workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from semapact.application.models.release import ReleasePlanningResult +from semapact.deployment import DeploymentAssessment + + +@dataclass(frozen=True) +class DeploymentWorkflowAssessment: + """Read-only governance + runtime assessment for one candidate revision.""" + + release: ReleasePlanningResult + deployment: DeploymentAssessment From 60a476b723c24fd77fd45c87a72d06386c6b1666 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:05:17 +1000 Subject: [PATCH 007/616] feat(application): compose contract-first deployment assessment --- .../services/deployment_workflow.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 semapact/application/services/deployment_workflow.py diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py new file mode 100644 index 00000000..10da6741 --- /dev/null +++ b/semapact/application/services/deployment_workflow.py @@ -0,0 +1,70 @@ +"""Canonical contract-first deployment workflow orchestration.""" + +from __future__ import annotations + +from datetime import date + +from open_data_contract_standard.model import OpenDataContractStandard + +from semapact.application.models.deployment_workflow import ( + DeploymentWorkflowAssessment, +) +from semapact.application.services.deployment import DeploymentService +from semapact.application.services.release_planning import ReleasePlanningService +from semapact.deployment import DeploymentAdapter, DeploymentTarget +from semapact.exceptions import ValidationError + + +class DeploymentWorkflowService: + """Compose release planning and runtime assessment without inventing authority.""" + + def __init__( + self, + *, + release_planning_service: ReleasePlanningService | None = None, + deployment_service: DeploymentService | None = None, + ) -> None: + self._release_planning = release_planning_service or ReleasePlanningService() + self._deployment = deployment_service or DeploymentService() + + def assess( + self, + base_contract: OpenDataContractStandard, + candidate_contract: OpenDataContractStandard, + *, + effective_date: date | str, + base_revision_ref: str, + candidate_revision_ref: str, + target: DeploymentTarget, + adapter: DeploymentAdapter, + authority_reference: str | None = None, + ) -> DeploymentWorkflowAssessment: + """Plan governed change and assess runtime without APPLY/DEPLOY authority.""" + release = self._release_planning.plan( + base_contract, + candidate_contract, + effective_date=effective_date, + base_revision_ref=base_revision_ref, + candidate_revision_ref=candidate_revision_ref, + authority_reference=authority_reference, + ) + deployment = self._deployment.assess( + candidate_contract, + candidate_revision_ref=candidate_revision_ref, + target=target, + adapter=adapter, + ) + + if deployment.contract_id != release.release_plan.contract_id: + raise ValidationError( + "Deployment assessment contract does not match release planning context" + ) + if deployment.candidate_revision_ref != release.release_plan.release_revision_ref: + raise ValidationError( + "Deployment assessment revision does not match release planning context" + ) + + return DeploymentWorkflowAssessment( + release=release, + deployment=deployment, + ) From 40edda9a741971b8b2d2420568b91df75159a512 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:05:25 +1000 Subject: [PATCH 008/616] feat(deployment): export assessment contracts --- semapact/deployment/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index 8a3a7c45..81bbf63d 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -14,7 +14,7 @@ NativeOperationKind, ) from semapact.deployment.orchestrator import DeploymentOrchestrator -from semapact.deployment.planner import build_deployment_plan +from semapact.deployment.planner import build_deployment_actions, build_deployment_plan from semapact.deployment.providers import ( DeploymentExecutionConfig, NativeOperationExecutor, @@ -29,6 +29,7 @@ "DeploymentAction", "DeploymentActionKind", "DeploymentAdapter", + "DeploymentAssessment", "DeploymentAuthorization", "DeploymentExecutionConfig", "DeploymentOrchestrator", @@ -42,6 +43,7 @@ "TransitionCompiler", "SchemaTransitionPlanner", "authorize_deployment", + "build_deployment_actions", "build_deployment_plan", "verify_deployment_convergence", ] From 184d9c6da78df25f51f6a1235bade6eee6eda7c4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:05:29 +1000 Subject: [PATCH 009/616] feat(application): export deployment workflow assessment --- semapact/application/models/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/semapact/application/models/__init__.py b/semapact/application/models/__init__.py index 34a27143..c03f18a4 100644 --- a/semapact/application/models/__init__.py +++ b/semapact/application/models/__init__.py @@ -1,5 +1,6 @@ """Typed application results composed from canonical domain artifacts.""" +from semapact.application.models.deployment_workflow import DeploymentWorkflowAssessment from semapact.application.models.evolution import ( BrokenHistoryReference, ContractEvolution, @@ -17,6 +18,7 @@ "BrokenHistoryReference", "ContractEvolution", "DeploymentEvolution", + "DeploymentWorkflowAssessment", "GovernanceAnalysis", "GovernanceProposal", "HistoryIntegrityReport", From 4c998627c22845b63cbefe7c85ced8f6337c7fd9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:05:37 +1000 Subject: [PATCH 010/616] fix(deployment): import assessment export --- semapact/deployment/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index 81bbf63d..8fce04af 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -6,6 +6,7 @@ from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, + DeploymentAssessment, DeploymentAuthorization, DeploymentPlan, DeploymentPreview, From f7dc385f31a4c4114b3610df3fde5fbffe48404f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:05:58 +1000 Subject: [PATCH 011/616] feat(cli): add contract-first deployment assessment --- .../interfaces/commands/deployment_cmd.py | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 26f14e6d..fec0d44c 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -23,6 +23,7 @@ from semapact.exceptions import ValidationError from semapact.interfaces.outcomes import ( ProcessOutcome, + outcome_from_gate_result, outcome_from_reconciliation_status, ) from semapact.reconciliation import classify_reconciliation_status @@ -39,6 +40,74 @@ class DeploymentCommandResult: outcome: ProcessOutcome +def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: + """Assess one candidate contract against fresh runtime without execution authority.""" + from semapact.application.services.deployment_workflow import ( + DeploymentWorkflowService, + ) + from semapact.core.loader import ContractLoader + from semapact.governance import GovernanceOperation, evaluate_governance_gate + from semapact.platforms.runtime_registry import ( + create_deployment_adapter, + resolve_runtime_location, + ) + + loader = ContractLoader(runtime_context=args.runtime_context) + base_contract = loader.load(args.base) + candidate_contract = loader.load(args.candidate) + location = resolve_runtime_location( + candidate_contract, + server_name=args.server, + fallback_platform=args.platform, + fallback_runtime_target=args.runtime, + ) + source_reference = _assessment_source_reference( + location.contract_server, + args.source_reference, + ) + target = DeploymentTarget( + platform=location.platform, + runtime_target=location.runtime_target, + source_reference=source_reference, + server_name=location.server_name, + ) + adapter = create_deployment_adapter( + location.platform, + contract_server=location.contract_server, + ) + result = DeploymentWorkflowService().assess( + base_contract, + candidate_contract, + effective_date=args.effective_date, + base_revision_ref=args.base_revision_ref, + candidate_revision_ref=args.candidate_revision_ref, + authority_reference=args.authority_reference, + target=target, + adapter=adapter, + ) + gate = evaluate_governance_gate( + result.release.decision, + GovernanceOperation.DEPLOY, + ) + payload = { + "executable": False, + "governanceDecision": result.release.decision.model_dump(mode="json"), + "changeSet": result.release.change_set.model_dump(mode="json"), + "releasePlan": result.release.release_plan.model_dump(mode="json"), + "versionResolution": result.release.version_resolution.model_dump(mode="json"), + "deploymentAssessment": result.deployment.model_dump(mode="json"), + } + rendered = ( + json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True) + if args.output == "json" + else _assessment_text(result) + ) + return DeploymentCommandResult( + output=rendered, + outcome=outcome_from_gate_result(gate), + ) + + def run_deployment_plan(args: argparse.Namespace) -> DeploymentCommandResult: """Build one provider-neutral DeploymentPlan from an exact applied release.""" release = _load_model(args.release, AppliedContractRelease) @@ -206,3 +275,56 @@ def _verification_text(status: str, result: BaseModel) -> str: else: lines.append("Unverified paths: none") return "\n".join(lines) + + + +def _assessment_source_reference(contract_server, cli_source_reference: str | None) -> str: + cli_value = (cli_source_reference or "").strip() + if contract_server is None: + if not cli_value: + raise ValidationError( + "Contract defines no server host; provide --source-reference" + ) + return cli_value + + host = str(getattr(contract_server, "host", "") or "").strip() + if not host: + raise ValidationError( + "Selected contract server must define host for deployment assessment" + ) + if cli_value and cli_value != host: + raise ValidationError( + "--source-reference cannot override the selected contract server host" + ) + return host + + +def _assessment_text(result) -> str: + release = result.release + assessment = result.deployment + lines = [ + f"Contract: {assessment.contract_id}@{assessment.candidate_version}", + f"Governance: {release.decision.decision.value}", + f"Required bump: {release.decision.required_version_bump}", + ( + f"Target: {assessment.target.platform}/" + f"{assessment.target.runtime_target}" + ), + "Executable: no (read-only assessment)", + "Operations:", + ] + for operation in assessment.operations: + detail = f" - {operation.kind.value} {operation.governed_asset}" + if operation.statement is not None: + detail += f": {operation.statement}" + lines.append(detail) + if not assessment.operations: + lines.append(" - none") + lines.extend( + [ + f"Observation source: {assessment.source_identifier}", + f"Observation fingerprint: {assessment.observation_fingerprint}", + f"Assessment id: {assessment.deployment_assessment_id}", + ] + ) + return "\n".join(lines) From 2fa44c2a636a30d0837fa5a10e59577a260ed24a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:06:06 +1000 Subject: [PATCH 012/616] feat(cli): expose deployment assess phase --- semapact/interfaces/cli.py | 42 +++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index abc20f60..3f21f8f9 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -449,6 +449,43 @@ def _build_parser() -> argparse.ArgumentParser: dest="deployment_command", required=True ) + deployment_assess_parser = deployment_subparsers.add_parser( + "assess", + help="Assess base/candidate contract change against fresh runtime without mutation", + ) + deployment_assess_parser.add_argument("--base", required=True) + deployment_assess_parser.add_argument("--candidate", required=True) + deployment_assess_parser.add_argument("--base-revision-ref", required=True) + deployment_assess_parser.add_argument("--candidate-revision-ref", required=True) + deployment_assess_parser.add_argument( + "--authority-reference", + help="Explicit Git release reference when release.versionAuthority=git", + ) + deployment_assess_parser.add_argument( + "--server", + help="Contract server identifier when the candidate defines multiple servers", + ) + deployment_assess_parser.add_argument( + "--platform", + help="Fallback runtime provider when the candidate defines no servers", + ) + deployment_assess_parser.add_argument( + "--runtime", + help="Fallback provider-local runtime target when the candidate defines no servers", + ) + deployment_assess_parser.add_argument( + "--source-reference", + help="Fallback stable runtime source identity when the candidate defines no server host", + ) + deployment_assess_parser.add_argument("--runtime-context", default="auto") + deployment_assess_parser.add_argument( + "--output", + choices=["text", "json"], + default="text", + help="Output format (default: text)", + ) + _add_effective_date_argument(deployment_assess_parser) + deployment_plan_parser = deployment_subparsers.add_parser( "plan", help="Build a DeploymentPlan from an exact AppliedContractRelease" ) @@ -607,6 +644,7 @@ def main() -> int: if args.command == "deployment": from semapact.interfaces.commands.deployment_cmd import ( + run_deployment_assess, run_deployment_execute, run_deployment_plan, run_deployment_preview, @@ -614,7 +652,9 @@ def main() -> int: ) from semapact.interfaces.outcomes import exit_code_from_outcome - if args.deployment_command == "plan": + if args.deployment_command == "assess": + result = run_deployment_assess(args) + elif args.deployment_command == "plan": result = run_deployment_plan(args) elif args.deployment_command == "preview": result = run_deployment_preview(args) From 05ef773ab98996eed09652523b5c591eec0966cd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:06:20 +1000 Subject: [PATCH 013/616] test(deployment): cover non-executable candidate assessment --- tests/test_deployment_orchestrator.py | 78 ++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/tests/test_deployment_orchestrator.py b/tests/test_deployment_orchestrator.py index 20b5de40..5eb4ce8a 100644 --- a/tests/test_deployment_orchestrator.py +++ b/tests/test_deployment_orchestrator.py @@ -4,17 +4,23 @@ import pytest -from open_data_contract_standard.model import SchemaObject, SchemaProperty +from open_data_contract_standard.model import ( + OpenDataContractStandard, + SchemaObject, + SchemaProperty, +) from semapact.deployment.compilers import TransitionCompiler from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, + DeploymentAssessment, DeploymentAuthorization, DeploymentPlan, DeploymentTarget, NativeOperation, NativeOperationKind, + compute_deployment_assessment_id, compute_deployment_authorization_id, compute_deployment_plan_id, ) @@ -171,6 +177,76 @@ def _observation() -> ObservedPlatformState: ) + + +def _candidate_contract() -> OpenDataContractStandard: + return OpenDataContractStandard.model_construct( + id="contract-1", + version="1.0.0", + schema_=[ + SchemaObject( + name="orders", + physicalName="orders", + physicalType="table", + properties=[ + SchemaProperty( + name="id", + logicalType="integer", + physicalType="BIGINT", + required=False, + ) + ], + ) + ], + servers=[], + ) + + +def test_generic_orchestrator_assesses_candidate_without_execution_authority() -> None: + runtime_provider = _RuntimeProvider(_observation()) + orchestrator = DeploymentOrchestrator( + runtime_provider=runtime_provider, + schema_mapper=PassThroughSchemaMapper(), + transition_planner=AdditiveSchemaTransitionPlanner(), + transition_compiler=_Compiler(), + executor=_Executor(), + ) + target = DeploymentTarget( + platform="fake", + runtime_target="main", + source_reference="source-1", + ) + + assessment = orchestrator.assess( + _candidate_contract(), + candidate_revision_ref="revision:candidate", + target=target, + ) + + assert isinstance(assessment, DeploymentAssessment) + assert assessment.contract_id == "contract-1" + assert assessment.candidate_revision_ref == "revision:candidate" + assert assessment.operations == ( + NativeOperation( + kind=NativeOperationKind.CREATE, + governed_asset="orders", + statement="CREATE_ASSET orders", + ), + ) + assert assessment.deployment_assessment_id == compute_deployment_assessment_id( + contract_id=assessment.contract_id, + candidate_revision_ref=assessment.candidate_revision_ref, + candidate_version=assessment.candidate_version, + target=assessment.target, + source_identifier=assessment.source_identifier, + observation_fingerprint=assessment.observation_fingerprint, + operations=assessment.operations, + ) + assert not hasattr(assessment, "applied_release_id") + assert not hasattr(assessment, "deployment_plan_id") + assert runtime_provider.observe_calls == 1 + + def test_generic_orchestrator_owns_observe_preview_freshness_and_execute() -> None: plan = _plan() runtime_provider = _RuntimeProvider(_observation()) From 54ee147499d77255de6e0e616e518385b189e63a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:06:32 +1000 Subject: [PATCH 014/616] test(application): cover deployment assessment delegation --- tests/test_deployment_service.py | 85 ++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/tests/test_deployment_service.py b/tests/test_deployment_service.py index 6cb90a3b..1d2355ff 100644 --- a/tests/test_deployment_service.py +++ b/tests/test_deployment_service.py @@ -3,12 +3,17 @@ from datetime import datetime, timezone import pytest -from open_data_contract_standard.model import SchemaObject, SchemaProperty +from open_data_contract_standard.model import ( + OpenDataContractStandard, + SchemaObject, + SchemaProperty, +) from semapact.deployment import ( DeploymentAction, DeploymentAdapter, DeploymentActionKind, + DeploymentAssessment, DeploymentAuthorization, DeploymentPlan, DeploymentPreview, @@ -17,6 +22,7 @@ NativeOperationKind, ) from semapact.deployment.models import ( + compute_deployment_assessment_id, compute_deployment_authorization_id, compute_deployment_plan_id, compute_deployment_preview_id, @@ -37,14 +43,22 @@ def __init__( self, preview: DeploymentPreview, verification: ReconciliationResult, + assessment: DeploymentAssessment | None = None, ) -> None: self.preview_result = preview self.verification_result = verification + self.assessment_result = assessment + self.assess_calls = 0 self.preview_calls = 0 self.verify_calls = 0 self.execute_calls = 0 self.executed = None + def assess(self, contract, *, candidate_revision_ref, target): + self.assess_calls += 1 + assert self.assessment_result is not None + return self.assessment_result + def validate(self, plan: DeploymentPlan) -> None: pass @@ -160,6 +174,44 @@ def _adapter(plan: DeploymentPlan) -> FakeDeploymentAdapter: return FakeDeploymentAdapter( _preview(plan), _verification(plan), + _assessment(plan), + ) + + +def _candidate_contract() -> OpenDataContractStandard: + return OpenDataContractStandard.model_construct( + id="orders-contract", + version="1.2.3", + schema_=[], + servers=[], + ) + + +def _assessment(plan: DeploymentPlan) -> DeploymentAssessment: + observation = _observation() + operation = NativeOperation( + kind=NativeOperationKind.NO_OP, + governed_asset="orders", + ) + assert observation.fingerprint is not None + assessment_id = compute_deployment_assessment_id( + contract_id="orders-contract", + candidate_revision_ref="candidate:abc", + candidate_version="1.2.3", + target=plan.target, + source_identifier=observation.source_identifier, + observation_fingerprint=observation.fingerprint, + operations=(operation,), + ) + return DeploymentAssessment( + deployment_assessment_id=assessment_id, + contract_id="orders-contract", + candidate_revision_ref="candidate:abc", + candidate_version="1.2.3", + target=plan.target, + source_identifier=observation.source_identifier, + observation_fingerprint=observation.fingerprint, + operations=(operation,), ) @@ -179,6 +231,26 @@ def _authorization(plan: DeploymentPlan) -> DeploymentAuthorization: ) + + +def test_assess_delegates_candidate_to_unified_adapter_entrypoint() -> None: + plan = _plan() + adapter = _adapter(plan) + + result = DeploymentService().assess( + _candidate_contract(), + candidate_revision_ref="candidate:abc", + target=plan.target, + adapter=adapter, + ) + + assert result == adapter.assessment_result + assert adapter.assess_calls == 1 + assert adapter.assess_calls == 0 + assert adapter.preview_calls == 0 + assert adapter.execute_calls == 0 + + def test_preview_delegates_to_unified_adapter_entrypoint() -> None: plan = _plan() adapter = _adapter(plan) @@ -217,14 +289,21 @@ def test_verify_delegates_to_same_adapter_entrypoint() -> None: assert adapter.verify_calls == 1 -@pytest.mark.parametrize("operation", ["preview", "verify", "execute"]) +@pytest.mark.parametrize("operation", ["assess", "preview", "verify", "execute"]) def test_service_rejects_adapter_platform_mismatch(operation: str) -> None: plan = _plan() adapter = _adapter(plan) adapter.key = "snowflake" with pytest.raises(ValidationError, match="Deployment adapter does not match"): - if operation == "preview": + if operation == "assess": + DeploymentService().assess( + _candidate_contract(), + candidate_revision_ref="candidate:abc", + target=plan.target, + adapter=adapter, + ) + elif operation == "preview": DeploymentService().preview(plan, adapter=adapter) elif operation == "verify": DeploymentService().verify(plan, adapter=adapter) From 94c7621c618f1ee7cecb2e58b0f152c3e86f0b33 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:06:41 +1000 Subject: [PATCH 015/616] fix(test): correct deployment assessment call assertions --- tests/test_deployment_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_deployment_service.py b/tests/test_deployment_service.py index 1d2355ff..ff188881 100644 --- a/tests/test_deployment_service.py +++ b/tests/test_deployment_service.py @@ -246,7 +246,6 @@ def test_assess_delegates_candidate_to_unified_adapter_entrypoint() -> None: assert result == adapter.assessment_result assert adapter.assess_calls == 1 - assert adapter.assess_calls == 0 assert adapter.preview_calls == 0 assert adapter.execute_calls == 0 @@ -315,6 +314,7 @@ def test_service_rejects_adapter_platform_mismatch(operation: str) -> None: adapter=adapter, ) + assert adapter.assess_calls == 0 assert adapter.preview_calls == 0 assert adapter.verify_calls == 0 assert adapter.execute_calls == 0 From 5bd2a93412fcc2d3595940f040914d67ce1ec0a9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:06:55 +1000 Subject: [PATCH 016/616] test(cli): cover contract-first deployment assessment surface --- tests/interfaces/test_deployment_cmd.py | 77 ++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index 7074c7be..2326dda0 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -3,7 +3,12 @@ import json import sys -from open_data_contract_standard.model import OpenDataContractStandard, SchemaObject, SchemaProperty +from open_data_contract_standard.model import ( + OpenDataContractStandard, + SchemaObject, + SchemaProperty, + Server, +) import pytest from semapact.contractops import AppliedContractRelease @@ -69,9 +74,29 @@ def _release() -> AppliedContractRelease: ) -def test_deployment_parser_exposes_four_explicit_phases() -> None: +def test_deployment_parser_exposes_read_only_assessment_and_explicit_phases() -> None: parser = cli._build_parser() + assess = parser.parse_args( + [ + "deployment", + "assess", + "--base", + "base.yaml", + "--candidate", + "candidate.yaml", + "--base-revision-ref", + "git:base", + "--candidate-revision-ref", + "git:candidate", + "--effective-date", + "2026-09-20", + "--server", + "production", + "--output", + "json", + ] + ) plan = parser.parse_args( [ "deployment", @@ -105,6 +130,9 @@ def test_deployment_parser_exposes_four_explicit_phases() -> None: ["deployment", "verify", "--plan", "plan.json", "--output", "json"] ) + assert assess.deployment_command == "assess" + assert assess.output == "json" + assert assess.server == "production" assert plan.deployment_command == "plan" assert plan.source_reference == SOURCE_REFERENCE assert preview.deployment_command == "preview" @@ -253,3 +281,48 @@ def verify(self, plan): assert result.outcome is ProcessOutcome.SUCCESS assert adapter.verify_calls == 1 assert json.loads(result.output)["status"] == "IN_SYNC" + + +def test_assessment_source_reference_prefers_contract_server_host() -> None: + server = Server.model_validate( + { + "server": "production", + "type": "databricks", + "host": SOURCE_REFERENCE, + "catalog": "main", + "schema": "silver", + } + ) + + assert ( + deployment_cmd._assessment_source_reference(server, None) + == SOURCE_REFERENCE + ) + + +def test_assessment_source_reference_requires_cli_fallback_without_server() -> None: + assert ( + deployment_cmd._assessment_source_reference(None, SOURCE_REFERENCE) + == SOURCE_REFERENCE + ) + + with pytest.raises(ValidationError, match="provide --source-reference"): + deployment_cmd._assessment_source_reference(None, None) + + +def test_assessment_source_reference_cannot_override_contract_host() -> None: + server = Server.model_validate( + { + "server": "production", + "type": "databricks", + "host": SOURCE_REFERENCE, + "catalog": "main", + "schema": "silver", + } + ) + + with pytest.raises(ValidationError, match="cannot override"): + deployment_cmd._assessment_source_reference( + server, + "https://other-workspace.example", + ) From cc09dffb9acfdc09abcee11822bb3add0b10c1ff Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:08:49 +1000 Subject: [PATCH 017/616] test(application): cover contract-first deployment workflow --- tests/test_deployment_workflow_service.py | 130 ++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 tests/test_deployment_workflow_service.py diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py new file mode 100644 index 00000000..d3ec7561 --- /dev/null +++ b/tests/test_deployment_workflow_service.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from open_data_contract_standard.model import ( + OpenDataContractStandard, + SchemaObject, + SchemaProperty, +) + +from semapact.application.services.deployment_workflow import DeploymentWorkflowService +from semapact.deployment import ( + DeploymentAdapter, + DeploymentAssessment, + DeploymentTarget, + NativeOperation, + NativeOperationKind, +) +from semapact.deployment.models import compute_deployment_assessment_id +from semapact.observation import ( + ObservedPlatformState, + with_observed_state_fingerprint, +) +from semapact.reconciliation import ReconciliationResult + + +class _AssessmentAdapter(DeploymentAdapter): + key = "fake" + + def __init__(self) -> None: + self.calls = 0 + + def assess(self, contract, *, candidate_revision_ref, target): + self.calls += 1 + observation = with_observed_state_fingerprint( + ObservedPlatformState( + platform="fake", + source_identifier=target.source_reference, + assets=(), + ) + ) + assert observation.fingerprint is not None + operations = ( + NativeOperation( + kind=NativeOperationKind.CREATE, + governed_asset="orders", + statement="CREATE orders", + ), + ) + assessment_id = compute_deployment_assessment_id( + contract_id=str(contract.id), + candidate_revision_ref=candidate_revision_ref, + candidate_version=str(contract.version), + target=target, + source_identifier=observation.source_identifier, + observation_fingerprint=observation.fingerprint, + operations=operations, + ) + return DeploymentAssessment( + deployment_assessment_id=assessment_id, + contract_id=str(contract.id), + candidate_revision_ref=candidate_revision_ref, + candidate_version=str(contract.version), + target=target, + source_identifier=observation.source_identifier, + observation_fingerprint=observation.fingerprint, + operations=operations, + ) + + def validate(self, plan): + raise AssertionError("assessment must not create or validate a DeploymentPlan") + + def preview(self, plan): + raise AssertionError("assessment must not create a DeploymentPreview") + + def execute(self, plan, preview, authorization): + raise AssertionError("assessment must not execute") + + def verify(self, plan) -> ReconciliationResult: + raise AssertionError("assessment must not verify a deployment") + + +def _contract(*, name: str) -> OpenDataContractStandard: + return OpenDataContractStandard( + apiVersion="v3.1.0", + kind="DataContract", + id="orders-product", + name=name, + version="1.2.3", + status="active", + schema=[ + SchemaObject( + name="orders", + properties=[ + SchemaProperty( + name="id", + logicalType="string", + physicalType="varchar(255)", + required=True, + ) + ], + ) + ], + ) + + +def test_workflow_assessment_composes_release_planning_and_runtime_assessment() -> None: + adapter = _AssessmentAdapter() + target = DeploymentTarget( + platform="fake", + runtime_target="main.sales", + source_reference="runtime:test", + ) + + result = DeploymentWorkflowService().assess( + _contract(name="Orders old"), + _contract(name="Orders new"), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=target, + adapter=adapter, + ) + + assert adapter.calls == 1 + assert result.release.change_set.base_revision_ref == "git:base" + assert result.release.release_plan.release_revision_ref == "git:candidate" + assert result.deployment.candidate_revision_ref == "git:candidate" + assert result.deployment.contract_id == result.release.release_plan.contract_id + assert result.deployment.operations[0].kind is NativeOperationKind.CREATE + assert not hasattr(result.deployment, "applied_release_id") + assert not hasattr(result.deployment, "deployment_plan_id") From a12f3ab9de49352ba0fe6451d9381b63615b804f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:09:21 +1000 Subject: [PATCH 018/616] docs(deployment): document pre-approval assessment --- docs/deployment_plans.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 38bb1f79..4cb5e248 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -144,9 +144,47 @@ Preview, execution, and verification fail closed when fresh runtime evidence com The plan does not contain credentials, workspace clients, SQL connections, or provider sessions. +## Candidate assessment before approval + +A Data Engineer or CI job can assess a proposed contract revision against fresh runtime state before any release or deployment authority exists: + +```text +base contract + candidate contract + ↓ +GovernanceDecision +→ ChangeSet +→ ReleasePlan +→ VersionResolution + + +fresh runtime observation + ↓ +DeploymentAssessment +``` + +`DeploymentAssessment` is deliberately non-executable. It binds the candidate revision, deployment target, fresh observation fingerprint, and provider-native CREATE / ALTER / NO_OP operations for review, but it contains no `AppliedContractRelease`, `DeploymentPlan`, or authorization identity. + +The assessment path reuses the same schema mapper, comparator, transition planner, and provider compiler as executable deployment preview. Approval does not promote the assessment itself into execution authority; post-approval deployment builds the canonical release/deployment artifacts and obtains fresh runtime evidence again. + ## CLI workflow -The deployment CLI consumes and emits canonical JSON artifacts. Planning and preview are read-only; `execute` is the runtime mutation boundary. +The deployment CLI supports both contract-first assessment and explicit canonical artifacts. Assessment, planning, and preview are read-only; `execute` is the runtime mutation boundary. + +### Assess + +```bash +semapact deployment assess \ + --base ./contracts/orders.yaml \ + --candidate ./contracts/orders.candidate.yaml \ + --base-revision-ref git:abc123 \ + --candidate-revision-ref git:def456 \ + --effective-date 2026-09-20 \ + --server production \ + --output json +``` + +When the candidate contract defines the selected server, its platform, catalog/schema target, and host are authoritative. For contracts without servers, provide `--platform`, `--runtime`, and `--source-reference`. + +The JSON output contains canonical release-planning artifacts plus `deploymentAssessment` and explicitly reports `"executable": false`. This surface is suitable for local review and CI; it does not create runtime mutation authority. ### Plan From d1960aa1697c5368958573acc5371930278d55a8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:09:23 +1000 Subject: [PATCH 019/616] docs(readme): expose contract-first deployment assessment --- README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index aff85e2c..eacda50c 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,10 @@ See [`docs/contractops_phases.md`](docs/contractops_phases.md) and [`docs/versio `semapact deployment` exposes a canonical CLI/CI surface: ```text +assess +→ base/candidate governance + fresh runtime DeploymentAssessment +→ read-only / non-executable + plan → DeploymentPlan @@ -329,7 +333,19 @@ pip install "semapact[databricks]" The Databricks SDK owns authentication-provider selection. SemaPact forwards supported connection hints rather than implementing a separate credential system. -After an exact `AppliedContractRelease` and deployment authorization have been produced, the runtime path is exposed through: +Before approval, assess the candidate contract against the selected Databricks target without creating execution authority: + +```bash +semapact deployment assess \ + --base ./contracts/orders.yaml \ + --candidate ./contracts/orders.candidate.yaml \ + --base-revision-ref git:abc123 \ + --candidate-revision-ref git:def456 \ + --effective-date 2026-09-20 \ + --server production +``` + +After an exact `AppliedContractRelease` and deployment authorization have been produced, the canonical runtime path remains: ```bash semapact deployment plan --help From e1359d265ce6cc26f88a421b0d091e905db4ad21 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 09:09:42 +1000 Subject: [PATCH 020/616] fix(test): timestamp workflow runtime evidence --- tests/test_deployment_workflow_service.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index d3ec7561..7c18b1fa 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import datetime, timezone + from open_data_contract_standard.model import ( OpenDataContractStandard, SchemaObject, @@ -35,6 +37,7 @@ def assess(self, contract, *, candidate_revision_ref, target): platform="fake", source_identifier=target.source_reference, assets=(), + captured_at=datetime(2026, 9, 20, tzinfo=timezone.utc), ) ) assert observation.fingerprint is not None From b5f63f6c261ed8deaf73880271c7520ec4d66f59 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:35:58 +1000 Subject: [PATCH 021/616] refactor(contractops): add pure release snapshot --- semapact/contractops/execution_models.py | 45 ++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/semapact/contractops/execution_models.py b/semapact/contractops/execution_models.py index 0604dfb1..918fbcb2 100644 --- a/semapact/contractops/execution_models.py +++ b/semapact/contractops/execution_models.py @@ -8,6 +8,51 @@ from semapact.contractops.models import ContractOpsModel +class ReleaseSnapshot(ContractOpsModel): + """Exact immutable released ODCS state before any external side effect.""" + + release_snapshot_id: str + contract_id: str + decision_id: str + change_set_id: str + release_plan_id: str + version_resolution_id: str + release_revision_ref: str + selected_version: str + released_contract_json: str + + @field_validator( + "release_snapshot_id", + "contract_id", + "decision_id", + "change_set_id", + "release_plan_id", + "version_resolution_id", + "release_revision_ref", + "selected_version", + "released_contract_json", + ) + @classmethod + def _require_snapshot_text(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("value must not be empty") + return cleaned + + @model_validator(mode="after") + def _validate_released_contract_snapshot(self) -> "ReleaseSnapshot": + contract = OpenDataContractStandard.model_validate_json(self.released_contract_json) + if str(contract.id or "") != self.contract_id: + raise ValueError("released contract ID does not match ReleaseSnapshot") + if str(contract.version or "") != self.selected_version: + raise ValueError("released contract version does not match selected_version") + return self + + def to_contract(self) -> OpenDataContractStandard: + """Materialize a fresh mutable ODCS model from the immutable JSON snapshot.""" + return OpenDataContractStandard.model_validate_json(self.released_contract_json) + + class AppliedContractRelease(ContractOpsModel): """Exact released ODCS state produced by an authorized APPLY operation.""" From 90512beb7a711c8381cc69767959cd3fd3939e65 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:36:07 +1000 Subject: [PATCH 022/616] refactor(contractops): add release snapshot integrity --- semapact/contractops/integrity.py | 50 ++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/semapact/contractops/integrity.py b/semapact/contractops/integrity.py index 1b606459..b400ea41 100644 --- a/semapact/contractops/integrity.py +++ b/semapact/contractops/integrity.py @@ -13,7 +13,11 @@ from collections.abc import Sequence from semapact.change_context import ChangeContext -from semapact.contractops.execution_models import AppliedContractRelease, PublicationResult +from semapact.contractops.execution_models import ( + AppliedContractRelease, + PublicationResult, + ReleaseSnapshot, +) from semapact.contractops.models import ( ChangeSet, ContractOpsAuthorization, @@ -37,6 +41,9 @@ SEMAPACT_CONTRACTOPS_AUTHORIZATION_NAMESPACE = uuid.UUID( "b6218d0c-3f9d-44a2-8d68-e3b0ee170948" ) +SEMAPACT_RELEASE_SNAPSHOT_NAMESPACE = uuid.UUID( + "d4248396-5ec7-4e6a-a238-b2db1abf76e8" +) SEMAPACT_APPLIED_RELEASE_NAMESPACE = uuid.UUID( "a5de2e65-aee7-48ac-9cb6-6a785f4cdf33" ) @@ -60,6 +67,8 @@ def validate_contractops_artifact_identity(artifact: object) -> None: validate_version_resolution_identity(artifact) elif isinstance(artifact, ContractOpsAuthorization): validate_contractops_authorization_identity(artifact) + elif isinstance(artifact, ReleaseSnapshot): + validate_release_snapshot_identity(artifact) elif isinstance(artifact, AppliedContractRelease): validate_applied_release_identity(artifact) elif isinstance(artifact, PublicationResult): @@ -235,6 +244,45 @@ def validate_contractops_authorization_identity( ) +def compute_release_snapshot_id( + *, + contract_id: str, + decision_id: str, + change_set_id: str, + release_plan_id: str, + version_resolution_id: str, + release_revision_ref: str, + selected_version: str, + released_contract_json: str, +) -> str: + payload = { + "contract_id": contract_id, + "decision_id": decision_id, + "change_set_id": change_set_id, + "release_plan_id": release_plan_id, + "version_resolution_id": version_resolution_id, + "release_revision_ref": release_revision_ref, + "selected_version": selected_version, + "released_contract_json": released_contract_json, + } + return deterministic_uuid5(SEMAPACT_RELEASE_SNAPSHOT_NAMESPACE, payload) + + +def validate_release_snapshot_identity(snapshot: ReleaseSnapshot) -> None: + _require_canonical_json(snapshot.released_contract_json, "ReleaseSnapshot snapshot") + expected = compute_release_snapshot_id( + contract_id=snapshot.contract_id, + decision_id=snapshot.decision_id, + change_set_id=snapshot.change_set_id, + release_plan_id=snapshot.release_plan_id, + version_resolution_id=snapshot.version_resolution_id, + release_revision_ref=snapshot.release_revision_ref, + selected_version=snapshot.selected_version, + released_contract_json=snapshot.released_contract_json, + ) + _require_identity(snapshot.release_snapshot_id, expected, "ReleaseSnapshot") + + def compute_applied_release_id( *, contract_id: str, From 85c9b157ed433eefe3d666423c5645d805242596 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:36:20 +1000 Subject: [PATCH 023/616] refactor(contractops): separate pure release snapshot from apply --- semapact/contractops/execution.py | 92 +++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/semapact/contractops/execution.py b/semapact/contractops/execution.py index 93b3ec4b..a0f7ef3d 100644 --- a/semapact/contractops/execution.py +++ b/semapact/contractops/execution.py @@ -7,14 +7,20 @@ from open_data_contract_standard.model import OpenDataContractStandard from semapact.contractops.context import validate_release_context -from semapact.contractops.execution_models import AppliedContractRelease, PublicationResult +from semapact.contractops.execution_models import ( + AppliedContractRelease, + PublicationResult, + ReleaseSnapshot, +) from semapact.contractops.integrity import ( SEMAPACT_APPLIED_RELEASE_NAMESPACE, SEMAPACT_PUBLICATION_NAMESPACE, compute_applied_release_id, + compute_release_snapshot_id, compute_publication_id, validate_applied_release_identity, validate_contractops_authorization_identity, + validate_release_snapshot_identity, ) from semapact.contractops.models import ( ChangeSet, @@ -37,7 +43,7 @@ def publish(self, release: AppliedContractRelease) -> str: ... -def apply_contract_release( +def build_release_snapshot( candidate_contract: OpenDataContractStandard, *, candidate_revision_ref: str, @@ -45,13 +51,8 @@ def apply_contract_release( change_set: ChangeSet, release_plan: ReleasePlan, version_resolution: VersionResolution, - authorization: ContractOpsAuthorization, -) -> AppliedContractRelease: - """Materialize the exact released ODCS state after explicit APPLY authorization. - - This is the canonical M2 apply path. It never re-runs governance, change - classification, or version authority. The input candidate is not mutated. - """ +) -> ReleaseSnapshot: + """Freeze one exact governed release without requiring side-effect authorization.""" if not isinstance(candidate_contract, OpenDataContractStandard): raise TypeError( "candidate_contract must be OpenDataContractStandard, " @@ -61,14 +62,6 @@ def apply_contract_release( raise TypeError("candidate_revision_ref must be str") validate_release_context(decision, change_set, release_plan, version_resolution) - _validate_authorization( - authorization, - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - operation=GovernanceOperation.APPLY, - ) supplied_revision_ref = candidate_revision_ref.strip() if not supplied_revision_ref: @@ -104,7 +97,7 @@ def apply_contract_release( released_contract.version = selected_version released_contract_json = canonical_contract_json(released_contract) - applied_release_id = compute_applied_release_id( + release_snapshot_id = compute_release_snapshot_id( contract_id=release_plan.contract_id, decision_id=decision.decision_id, change_set_id=change_set.change_set_id, @@ -112,12 +105,10 @@ def apply_contract_release( version_resolution_id=version_resolution.version_resolution_id, release_revision_ref=release_plan.release_revision_ref, selected_version=selected_version, - authorization_id=authorization.authorization_id, released_contract_json=released_contract_json, ) - - return AppliedContractRelease( - applied_release_id=applied_release_id, + snapshot = ReleaseSnapshot( + release_snapshot_id=release_snapshot_id, contract_id=release_plan.contract_id, decision_id=decision.decision_id, change_set_id=change_set.change_set_id, @@ -125,9 +116,64 @@ def apply_contract_release( version_resolution_id=version_resolution.version_resolution_id, release_revision_ref=release_plan.release_revision_ref, selected_version=selected_version, - authorization_id=authorization.authorization_id, released_contract_json=released_contract_json, ) + validate_release_snapshot_identity(snapshot) + return snapshot + + +def apply_contract_release( + candidate_contract: OpenDataContractStandard, + *, + candidate_revision_ref: str, + decision: GovernanceDecision, + change_set: ChangeSet, + release_plan: ReleasePlan, + version_resolution: VersionResolution, + authorization: ContractOpsAuthorization, +) -> AppliedContractRelease: + """Compatibility APPLY boundary wrapping the pure ReleaseSnapshot.""" + _validate_authorization( + authorization, + decision=decision, + change_set=change_set, + release_plan=release_plan, + version_resolution=version_resolution, + operation=GovernanceOperation.APPLY, + ) + snapshot = build_release_snapshot( + candidate_contract, + candidate_revision_ref=candidate_revision_ref, + decision=decision, + change_set=change_set, + release_plan=release_plan, + version_resolution=version_resolution, + ) + + applied_release_id = compute_applied_release_id( + contract_id=snapshot.contract_id, + decision_id=snapshot.decision_id, + change_set_id=snapshot.change_set_id, + release_plan_id=snapshot.release_plan_id, + version_resolution_id=snapshot.version_resolution_id, + release_revision_ref=snapshot.release_revision_ref, + selected_version=snapshot.selected_version, + authorization_id=authorization.authorization_id, + released_contract_json=snapshot.released_contract_json, + ) + + return AppliedContractRelease( + applied_release_id=applied_release_id, + contract_id=snapshot.contract_id, + decision_id=snapshot.decision_id, + change_set_id=snapshot.change_set_id, + release_plan_id=snapshot.release_plan_id, + version_resolution_id=snapshot.version_resolution_id, + release_revision_ref=snapshot.release_revision_ref, + selected_version=snapshot.selected_version, + authorization_id=authorization.authorization_id, + released_contract_json=snapshot.released_contract_json, + ) def publish_contract_release( From 3e9789d229021c3c6bd51b1ba17fbb5e76245353 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:36:28 +1000 Subject: [PATCH 024/616] refactor(contractops): export release snapshot boundary --- semapact/contractops/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/semapact/contractops/__init__.py b/semapact/contractops/__init__.py index b95e5f8f..24658522 100644 --- a/semapact/contractops/__init__.py +++ b/semapact/contractops/__init__.py @@ -8,14 +8,17 @@ from semapact.contractops.execution import ( ContractReleasePublisher, apply_contract_release, + build_release_snapshot, publish_contract_release, ) from semapact.contractops.execution_models import ( AppliedContractRelease, PublicationResult, + ReleaseSnapshot, ) from semapact.contractops.integrity import ( validate_applied_release_identity, + validate_release_snapshot_identity, validate_change_set_identity, validate_contractops_authorization_identity, validate_publication_result_identity, @@ -42,6 +45,7 @@ __all__ = [ "AppliedContractRelease", + "ReleaseSnapshot", "AuthorizationReason", "ChangeSet", "ContractOpsAuthorization", @@ -55,6 +59,7 @@ "VersionAuthorityConfig", "VersionResolution", "apply_contract_release", + "build_release_snapshot", "authorize_contract_operation", "build_change_set", "build_change_set_from_decision", @@ -63,6 +68,7 @@ "publish_contract_release", "resolve_release_version", "validate_applied_release_identity", + "validate_release_snapshot_identity", "validate_change_set_identity", "validate_contractops_authorization_identity", "validate_publication_result_identity", From d44070c1ffcd27a845b91e3cd85b69617fbaab53 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:36:43 +1000 Subject: [PATCH 025/616] refactor(deployment): generalize plan release identity --- semapact/deployment/models.py | 59 +++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index f4063110..ca85b777 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -7,7 +7,14 @@ from typing import Literal, Sequence from open_data_contract_standard.model import SchemaObject -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) from semapact.lifecycle.identity import normalize_identity_name from semapact.utils.deterministic import deterministic_uuid5 @@ -122,21 +129,23 @@ def _validate_desired_state_identity(self) -> DeploymentAction: class DeploymentPlan(DeploymentModel): - """Pure deterministic runtime convergence plan for one applied release.""" + """Pure deterministic runtime convergence plan for one exact release snapshot.""" deployment_plan_id: str - applied_release_id: str + release_id: str = Field( + validation_alias=AliasChoices("release_id", "applied_release_id") + ) contract_id: str release_plan_id: str released_revision_ref: str selected_version: str target: DeploymentTarget actions: tuple[DeploymentAction, ...] - plan_version: Literal["2"] = "2" + plan_version: Literal["2", "3"] = "3" @field_validator( "deployment_plan_id", - "applied_release_id", + "release_id", "contract_id", "release_plan_id", "released_revision_ref", @@ -159,6 +168,11 @@ def _validate_action_order_and_identity(self) -> DeploymentPlan: validate_deployment_plan_identity(self) return self + @property + def applied_release_id(self) -> str: + """Compatibility alias for serialized v2 deployment plans.""" + return self.release_id + class NativeOperation(DeploymentModel): """One immutable provider-native operation selected by an adapter preview.""" @@ -291,19 +305,28 @@ def _validate_identity(self) -> DeploymentAuthorization: def compute_deployment_plan_id( *, - applied_release_id: str, + release_id: str | None = None, + applied_release_id: str | None = None, contract_id: str, release_plan_id: str, released_revision_ref: str, selected_version: str, target: DeploymentTarget, actions: Sequence[DeploymentAction], - plan_version: str = "2", + plan_version: str = "3", ) -> str: + resolved_release_id = _resolve_release_id( + release_id=release_id, + applied_release_id=applied_release_id, + ) + if plan_version not in {"2", "3"}: + raise ValueError(f"Unsupported DeploymentPlan version: {plan_version}") + + release_key = "applied_release_id" if plan_version == "2" else "release_id" return deterministic_uuid5( SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, { - "applied_release_id": applied_release_id, + release_key: resolved_release_id, "contract_id": contract_id, "release_plan_id": release_plan_id, "released_revision_ref": released_revision_ref, @@ -315,6 +338,7 @@ def compute_deployment_plan_id( ) + def compute_deployment_assessment_id( *, contract_id: str, @@ -383,9 +407,26 @@ def compute_deployment_preview_id( ) +def _resolve_release_id( + *, + release_id: str | None, + applied_release_id: str | None, +) -> str: + values = { + value.strip() + for value in (release_id, applied_release_id) + if isinstance(value, str) and value.strip() + } + if not values: + raise ValueError("release_id is required") + if len(values) != 1: + raise ValueError("release_id and applied_release_id must identify the same release") + return values.pop() + + def validate_deployment_plan_identity(plan: DeploymentPlan) -> None: expected = compute_deployment_plan_id( - applied_release_id=plan.applied_release_id, + release_id=plan.release_id, contract_id=plan.contract_id, release_plan_id=plan.release_plan_id, released_revision_ref=plan.released_revision_ref, From df418e598ae290a0b8193755c3e3e1ed745bb5ec Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:36:54 +1000 Subject: [PATCH 026/616] refactor(deployment): build plans from release snapshots --- semapact/deployment/planner.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/semapact/deployment/planner.py b/semapact/deployment/planner.py index 47520582..0251ba88 100644 --- a/semapact/deployment/planner.py +++ b/semapact/deployment/planner.py @@ -4,8 +4,11 @@ from open_data_contract_standard.model import OpenDataContractStandard, SchemaObject -from semapact.contractops.execution_models import AppliedContractRelease -from semapact.contractops.integrity import validate_applied_release_identity +from semapact.contractops.execution_models import AppliedContractRelease, ReleaseSnapshot +from semapact.contractops.integrity import ( + validate_applied_release_identity, + validate_release_snapshot_identity, +) from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, @@ -19,7 +22,7 @@ def build_deployment_plan( - release: AppliedContractRelease, + release: ReleaseSnapshot | AppliedContractRelease, target: DeploymentTarget, ) -> DeploymentPlan: """Build one deterministic provider-neutral convergence plan. @@ -27,37 +30,48 @@ def build_deployment_plan( Planning consumes exact released desired state only. It does not observe runtime, choose CREATE/ALTER/DROP operations, contact a provider, or recompute governance. """ - if not isinstance(release, AppliedContractRelease): + if not isinstance(release, (ReleaseSnapshot, AppliedContractRelease)): raise TypeError( - f"release must be AppliedContractRelease, got {type(release).__name__}" + "release must be ReleaseSnapshot or AppliedContractRelease, " + f"got {type(release).__name__}" ) if not isinstance(target, DeploymentTarget): raise TypeError( f"target must be DeploymentTarget, got {type(target).__name__}" ) - validate_applied_release_identity(release) + if isinstance(release, ReleaseSnapshot): + validate_release_snapshot_identity(release) + release_id = release.release_snapshot_id + plan_version = "3" + else: + validate_applied_release_identity(release) + release_id = release.applied_release_id + plan_version = "2" + contract = release.to_contract() ordered_actions = build_deployment_actions(contract) deployment_plan_id = compute_deployment_plan_id( - applied_release_id=release.applied_release_id, + release_id=release_id, contract_id=release.contract_id, release_plan_id=release.release_plan_id, released_revision_ref=release.release_revision_ref, selected_version=release.selected_version, target=target, actions=ordered_actions, + plan_version=plan_version, ) return DeploymentPlan( deployment_plan_id=deployment_plan_id, - applied_release_id=release.applied_release_id, + release_id=release_id, contract_id=release.contract_id, release_plan_id=release.release_plan_id, released_revision_ref=release.release_revision_ref, selected_version=release.selected_version, target=target, actions=ordered_actions, + plan_version=plan_version, ) @@ -67,7 +81,7 @@ def build_deployment_actions( """Project candidate/released ODCS state into provider-neutral desired actions. Actions are desired-state facts only. They are not executable authority until - they are bound into an exact DeploymentPlan derived from an AppliedContractRelease. + they are bound into an exact DeploymentPlan derived from a release snapshot. """ if not isinstance(contract, OpenDataContractStandard): raise TypeError( From a90987a8987d96460fe47174e99ebafe059852f8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:37:16 +1000 Subject: [PATCH 027/616] feat(application): add content-addressed deployment bundle --- .../application/models/deployment_workflow.py | 155 +++++++++++++++++- 1 file changed, 146 insertions(+), 9 deletions(-) diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py index 73f25bd5..e0558540 100644 --- a/semapact/application/models/deployment_workflow.py +++ b/semapact/application/models/deployment_workflow.py @@ -1,16 +1,153 @@ -"""Application result models for contract-first deployment workflows.""" +"""Application artifacts for contract-first deployment workflows.""" from __future__ import annotations -from dataclasses import dataclass +import hashlib +from typing import Literal -from semapact.application.models.release import ReleasePlanningResult -from semapact.deployment import DeploymentAssessment +from pydantic import BaseModel, ConfigDict, model_validator +from semapact.contractops import ( + ChangeSet, + ReleasePlan, + ReleaseSnapshot, + VersionResolution, +) +from semapact.deployment import DeploymentPlan, DeploymentPreview +from semapact.governance import GovernanceDecision +from semapact.utils.deterministic import canonical_compact_json -@dataclass(frozen=True) -class DeploymentWorkflowAssessment: - """Read-only governance + runtime assessment for one candidate revision.""" - release: ReleasePlanningResult - deployment: DeploymentAssessment +class DeploymentBundle(BaseModel): + """Immutable CI-to-CD package around existing canonical domain artifacts. + + The bundle is transport and integrity boundary only. It does not add deployment + authority; approval and authorization remain separate facts. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + bundle_digest: str + decision: GovernanceDecision + change_set: ChangeSet + release_plan: ReleasePlan + version_resolution: VersionResolution + release_snapshot: ReleaseSnapshot + deployment_plan: DeploymentPlan + review_preview: DeploymentPreview + bundle_version: Literal["1"] = "1" + + @model_validator(mode="after") + def _validate_bundle_links(self) -> "DeploymentBundle": + if self.change_set.contract_id != self.decision.contract_id: + raise ValueError("DeploymentBundle decision/change-set contract mismatch") + if self.release_plan.change_set_id != self.change_set.change_set_id: + raise ValueError("DeploymentBundle release plan does not match ChangeSet") + if self.release_plan.decision_id != self.decision.decision_id: + raise ValueError("DeploymentBundle release plan does not match decision") + if self.version_resolution.release_plan_id != self.release_plan.release_plan_id: + raise ValueError( + "DeploymentBundle version resolution does not match ReleasePlan" + ) + + snapshot = self.release_snapshot + if snapshot.decision_id != self.decision.decision_id: + raise ValueError("DeploymentBundle snapshot does not match decision") + if snapshot.change_set_id != self.change_set.change_set_id: + raise ValueError("DeploymentBundle snapshot does not match ChangeSet") + if snapshot.release_plan_id != self.release_plan.release_plan_id: + raise ValueError("DeploymentBundle snapshot does not match ReleasePlan") + if ( + snapshot.version_resolution_id + != self.version_resolution.version_resolution_id + ): + raise ValueError( + "DeploymentBundle snapshot does not match VersionResolution" + ) + + plan = self.deployment_plan + if plan.release_id != snapshot.release_snapshot_id: + raise ValueError("DeploymentBundle plan does not match ReleaseSnapshot") + if plan.contract_id != snapshot.contract_id: + raise ValueError("DeploymentBundle plan/snapshot contract mismatch") + if plan.release_plan_id != snapshot.release_plan_id: + raise ValueError("DeploymentBundle plan/snapshot release-plan mismatch") + if plan.released_revision_ref != snapshot.release_revision_ref: + raise ValueError("DeploymentBundle plan/snapshot revision mismatch") + if plan.selected_version != snapshot.selected_version: + raise ValueError("DeploymentBundle plan/snapshot version mismatch") + + if self.review_preview.deployment_plan_id != plan.deployment_plan_id: + raise ValueError("DeploymentBundle preview does not match DeploymentPlan") + + expected = compute_deployment_bundle_digest( + decision=self.decision, + change_set=self.change_set, + release_plan=self.release_plan, + version_resolution=self.version_resolution, + release_snapshot=self.release_snapshot, + deployment_plan=self.deployment_plan, + review_preview=self.review_preview, + bundle_version=self.bundle_version, + ) + if self.bundle_digest != expected: + raise ValueError("DeploymentBundle digest does not match content") + return self + + +def build_deployment_bundle( + *, + decision: GovernanceDecision, + change_set: ChangeSet, + release_plan: ReleasePlan, + version_resolution: VersionResolution, + release_snapshot: ReleaseSnapshot, + deployment_plan: DeploymentPlan, + review_preview: DeploymentPreview, +) -> DeploymentBundle: + """Package exact CI material without introducing another authority artifact.""" + digest = compute_deployment_bundle_digest( + decision=decision, + change_set=change_set, + release_plan=release_plan, + version_resolution=version_resolution, + release_snapshot=release_snapshot, + deployment_plan=deployment_plan, + review_preview=review_preview, + ) + return DeploymentBundle( + bundle_digest=digest, + decision=decision, + change_set=change_set, + release_plan=release_plan, + version_resolution=version_resolution, + release_snapshot=release_snapshot, + deployment_plan=deployment_plan, + review_preview=review_preview, + ) + + +def compute_deployment_bundle_digest( + *, + decision: GovernanceDecision, + change_set: ChangeSet, + release_plan: ReleasePlan, + version_resolution: VersionResolution, + release_snapshot: ReleaseSnapshot, + deployment_plan: DeploymentPlan, + review_preview: DeploymentPreview, + bundle_version: str = "1", +) -> str: + """Return a content digest suitable for CI artifact pinning and approval evidence.""" + payload = { + "bundle_version": bundle_version, + "decision": decision.model_dump(mode="json"), + "change_set": change_set.model_dump(mode="json"), + "release_plan": release_plan.model_dump(mode="json"), + "version_resolution": version_resolution.model_dump(mode="json"), + "release_snapshot": release_snapshot.model_dump(mode="json"), + "deployment_plan": deployment_plan.model_dump(mode="json"), + "review_preview": review_preview.model_dump(mode="json"), + } + encoded = canonical_compact_json(payload).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" From d313d4590b070e0e3d7a8b596bce8266235c8206 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:37:22 +1000 Subject: [PATCH 028/616] refactor(application): export deployment bundle --- semapact/application/models/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/semapact/application/models/__init__.py b/semapact/application/models/__init__.py index c03f18a4..d343ffc0 100644 --- a/semapact/application/models/__init__.py +++ b/semapact/application/models/__init__.py @@ -1,6 +1,10 @@ """Typed application results composed from canonical domain artifacts.""" -from semapact.application.models.deployment_workflow import DeploymentWorkflowAssessment +from semapact.application.models.deployment_workflow import ( + DeploymentBundle, + build_deployment_bundle, + compute_deployment_bundle_digest, +) from semapact.application.models.evolution import ( BrokenHistoryReference, ContractEvolution, @@ -18,7 +22,9 @@ "BrokenHistoryReference", "ContractEvolution", "DeploymentEvolution", - "DeploymentWorkflowAssessment", + "DeploymentBundle", + "build_deployment_bundle", + "compute_deployment_bundle_digest", "GovernanceAnalysis", "GovernanceProposal", "HistoryIntegrityReport", From 9c3a2b11a5860aabe03ef3a95d02f5ef8829f8fe Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:37:34 +1000 Subject: [PATCH 029/616] refactor(application): build deployment bundle from canonical plan --- .../services/deployment_workflow.py | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index 10da6741..bfbb20bb 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -7,12 +7,13 @@ from open_data_contract_standard.model import OpenDataContractStandard from semapact.application.models.deployment_workflow import ( - DeploymentWorkflowAssessment, + DeploymentBundle, + build_deployment_bundle, ) from semapact.application.services.deployment import DeploymentService from semapact.application.services.release_planning import ReleasePlanningService +from semapact.contractops import build_release_snapshot from semapact.deployment import DeploymentAdapter, DeploymentTarget -from semapact.exceptions import ValidationError class DeploymentWorkflowService: @@ -38,8 +39,8 @@ def assess( target: DeploymentTarget, adapter: DeploymentAdapter, authority_reference: str | None = None, - ) -> DeploymentWorkflowAssessment: - """Plan governed change and assess runtime without APPLY/DEPLOY authority.""" + ) -> DeploymentBundle: + """Build the immutable CI handoff bundle without side-effect authorization.""" release = self._release_planning.plan( base_contract, candidate_contract, @@ -48,23 +49,22 @@ def assess( candidate_revision_ref=candidate_revision_ref, authority_reference=authority_reference, ) - deployment = self._deployment.assess( + snapshot = build_release_snapshot( candidate_contract, candidate_revision_ref=candidate_revision_ref, - target=target, - adapter=adapter, + decision=release.decision, + change_set=release.change_set, + release_plan=release.release_plan, + version_resolution=release.version_resolution, ) - - if deployment.contract_id != release.release_plan.contract_id: - raise ValidationError( - "Deployment assessment contract does not match release planning context" - ) - if deployment.candidate_revision_ref != release.release_plan.release_revision_ref: - raise ValidationError( - "Deployment assessment revision does not match release planning context" - ) - - return DeploymentWorkflowAssessment( - release=release, - deployment=deployment, + plan = self._deployment.plan(snapshot, target) + preview = self._deployment.preview(plan, adapter=adapter) + return build_deployment_bundle( + decision=release.decision, + change_set=release.change_set, + release_plan=release.release_plan, + version_resolution=release.version_resolution, + release_snapshot=snapshot, + deployment_plan=plan, + review_preview=preview, ) From 731f49f6e4e298bd270af66d139989153eb71229 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:37:37 +1000 Subject: [PATCH 030/616] refactor(application): plan deployment from release snapshot --- semapact/application/services/deployment.py | 22 ++------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/semapact/application/services/deployment.py b/semapact/application/services/deployment.py index 6b4de767..aff76147 100644 --- a/semapact/application/services/deployment.py +++ b/semapact/application/services/deployment.py @@ -2,12 +2,9 @@ from __future__ import annotations -from open_data_contract_standard.model import OpenDataContractStandard - -from semapact.contractops import AppliedContractRelease +from semapact.contractops import AppliedContractRelease, ReleaseSnapshot from semapact.deployment import ( DeploymentAdapter, - DeploymentAssessment, DeploymentAuthorization, DeploymentPlan, DeploymentPreview, @@ -21,24 +18,9 @@ class DeploymentService: """Application boundary over the generic deployment lifecycle.""" - def assess( - self, - contract: OpenDataContractStandard, - *, - candidate_revision_ref: str, - target: DeploymentTarget, - adapter: DeploymentAdapter, - ) -> DeploymentAssessment: - _validate_component_key(adapter.key, target.platform, "deployment adapter") - return adapter.assess( - contract, - candidate_revision_ref=candidate_revision_ref, - target=target, - ) - def plan( self, - release: AppliedContractRelease, + release: ReleaseSnapshot | AppliedContractRelease, target: DeploymentTarget, ) -> DeploymentPlan: return build_deployment_plan(release, target) From 0c0167eb1015548179c55dceb8d859b585cd78f5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:37:51 +1000 Subject: [PATCH 031/616] refactor(deployment): remove duplicate candidate assessment port --- semapact/deployment/adapters.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/semapact/deployment/adapters.py b/semapact/deployment/adapters.py index 974ba0cb..5bfbe689 100644 --- a/semapact/deployment/adapters.py +++ b/semapact/deployment/adapters.py @@ -4,14 +4,10 @@ from abc import ABC, abstractmethod -from open_data_contract_standard.model import OpenDataContractStandard - from semapact.deployment.models import ( - DeploymentAssessment, DeploymentAuthorization, DeploymentPlan, DeploymentPreview, - DeploymentTarget, ) from semapact.reconciliation import ReconciliationResult @@ -21,18 +17,6 @@ class DeploymentAdapter(ABC): key: str - def assess( - self, - contract: OpenDataContractStandard, - *, - candidate_revision_ref: str, - target: DeploymentTarget, - ) -> DeploymentAssessment: - """Observe runtime and assess one candidate without creating execution authority.""" - raise NotImplementedError( - f"{type(self).__name__} does not support candidate deployment assessment" - ) - @abstractmethod def validate(self, plan: DeploymentPlan) -> None: """Validate one exact DeploymentPlan.""" From d76a2473df4feaff05d0cf9b3e0554e104cdb0cd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:37:53 +1000 Subject: [PATCH 032/616] refactor(deployment): reuse canonical preview for CI assessment --- semapact/deployment/orchestrator.py | 70 +---------------------------- 1 file changed, 1 insertion(+), 69 deletions(-) diff --git a/semapact/deployment/orchestrator.py b/semapact/deployment/orchestrator.py index 529cc623..8105a659 100644 --- a/semapact/deployment/orchestrator.py +++ b/semapact/deployment/orchestrator.py @@ -2,27 +2,24 @@ from __future__ import annotations -from open_data_contract_standard.model import OpenDataContractStandard, SchemaObject +from open_data_contract_standard.model import SchemaObject from semapact.deployment.adapters import DeploymentAdapter from semapact.deployment.compilers import TransitionCompiler from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, - DeploymentAssessment, DeploymentAuthorization, DeploymentPlan, DeploymentPreview, DeploymentTarget, NativeOperation, NativeOperationKind, - compute_deployment_assessment_id, compute_deployment_preview_id, validate_deployment_authorization_identity, validate_deployment_plan_identity, validate_deployment_preview_identity, ) -from semapact.deployment.planner import build_deployment_actions from semapact.deployment.providers import NativeOperationExecutor from semapact.deployment.schema_transitions import SchemaTransitionPlanner from semapact.deployment.verification import verify_deployment_convergence @@ -74,71 +71,6 @@ def __init__( def key(self) -> str: return self._runtime_provider.key - def assess( - self, - contract: OpenDataContractStandard, - *, - candidate_revision_ref: str, - target: DeploymentTarget, - ) -> DeploymentAssessment: - """Assess candidate desired state against fresh runtime without authority.""" - if not isinstance(contract, OpenDataContractStandard): - raise TypeError( - "contract must be OpenDataContractStandard, " - f"got {type(contract).__name__}" - ) - if not isinstance(target, DeploymentTarget): - raise TypeError( - f"target must be DeploymentTarget, got {type(target).__name__}" - ) - - contract_id = str(contract.id or "").strip() - candidate_version = str(contract.version or "").strip() - revision_ref = str(candidate_revision_ref).strip() - if not contract_id: - raise ValidationError("Candidate contract id is required for deployment assessment") - if not candidate_version: - raise ValidationError( - "Candidate contract version is required for deployment assessment" - ) - if not revision_ref: - raise ValidationError( - "candidate_revision_ref is required for deployment assessment" - ) - - actions = build_deployment_actions(contract) - desired_by_action = self._validate_and_map_actions(target, actions) - observed_state, bindings = self._observe_scope(target, actions) - operations = self._operations_for_scope( - target, - actions, - observed_state, - bindings=bindings, - desired_by_action=desired_by_action, - ) - if observed_state.fingerprint is None: - raise ValidationError("Runtime observation fingerprint is required") - - assessment_id = compute_deployment_assessment_id( - contract_id=contract_id, - candidate_revision_ref=revision_ref, - candidate_version=candidate_version, - target=target, - source_identifier=observed_state.source_identifier, - observation_fingerprint=observed_state.fingerprint, - operations=operations, - ) - return DeploymentAssessment( - deployment_assessment_id=assessment_id, - contract_id=contract_id, - candidate_revision_ref=revision_ref, - candidate_version=candidate_version, - target=target, - source_identifier=observed_state.source_identifier, - observation_fingerprint=observed_state.fingerprint, - operations=operations, - ) - def validate(self, plan: DeploymentPlan) -> None: """Validate one exact plan without runtime observation side effects.""" self._validate_and_map_plan(plan) From 6fc7a3e793765ca625179e84c2841c46e49ef9cf Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:38:00 +1000 Subject: [PATCH 033/616] refactor(deployment): drop assessment authority artifact --- semapact/deployment/models.py | 90 ----------------------------------- 1 file changed, 90 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index ca85b777..ffcb7be1 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -29,9 +29,6 @@ SEMAPACT_DEPLOYMENT_PREVIEW_NAMESPACE = uuid.UUID( "0ee613b9-a9ef-4a95-ac87-25d6c706b16c" ) -SEMAPACT_DEPLOYMENT_ASSESSMENT_NAMESPACE = uuid.UUID( - "7dfe1ca4-bd3e-4a8c-b89f-52217c326f52" -) class DeploymentModel(BaseModel): @@ -233,48 +230,6 @@ def _validate_identity(self) -> DeploymentPreview: return self -class DeploymentAssessment(DeploymentModel): - """Read-only runtime assessment for one candidate contract revision. - - This artifact intentionally carries no AppliedContractRelease, DeploymentPlan, - or authorization identity and therefore cannot be used as execution authority. - """ - - deployment_assessment_id: str - contract_id: str - candidate_revision_ref: str - candidate_version: str - target: DeploymentTarget - source_identifier: str - observation_fingerprint: str - operations: tuple[NativeOperation, ...] - assessment_version: Literal["1"] = "1" - - @field_validator( - "deployment_assessment_id", - "contract_id", - "candidate_revision_ref", - "candidate_version", - "source_identifier", - "observation_fingerprint", - ) - @classmethod - def _require_assessment_text(cls, value: str) -> str: - cleaned = value.strip() - if not cleaned: - raise ValueError("assessment fields must not be empty") - return cleaned - - @model_validator(mode="after") - def _validate_identity(self) -> "DeploymentAssessment": - if self.source_identifier != self.target.source_reference: - raise ValueError( - "DeploymentAssessment source identifier must match target source reference" - ) - validate_deployment_assessment_identity(self) - return self - - class DeploymentAuthorization(DeploymentModel): """Authorization bound to one exact DeploymentPlan and applied release.""" @@ -339,32 +294,6 @@ def compute_deployment_plan_id( -def compute_deployment_assessment_id( - *, - contract_id: str, - candidate_revision_ref: str, - candidate_version: str, - target: DeploymentTarget, - source_identifier: str, - observation_fingerprint: str, - operations: Sequence[NativeOperation], - assessment_version: str = "1", -) -> str: - return deterministic_uuid5( - SEMAPACT_DEPLOYMENT_ASSESSMENT_NAMESPACE, - { - "contract_id": contract_id, - "candidate_revision_ref": candidate_revision_ref, - "candidate_version": candidate_version, - "target": target.model_dump(mode="json"), - "source_identifier": source_identifier, - "observation_fingerprint": observation_fingerprint, - "operations": [operation.model_dump(mode="json") for operation in operations], - "assessment_version": assessment_version, - }, - ) - - def compute_deployment_authorization_id( *, contract_ops_authorization_id: str, @@ -439,25 +368,6 @@ def validate_deployment_plan_identity(plan: DeploymentPlan) -> None: raise ValueError("DeploymentPlan deterministic identity does not match content") -def validate_deployment_assessment_identity( - assessment: DeploymentAssessment, -) -> None: - expected = compute_deployment_assessment_id( - contract_id=assessment.contract_id, - candidate_revision_ref=assessment.candidate_revision_ref, - candidate_version=assessment.candidate_version, - target=assessment.target, - source_identifier=assessment.source_identifier, - observation_fingerprint=assessment.observation_fingerprint, - operations=assessment.operations, - assessment_version=assessment.assessment_version, - ) - if expected != assessment.deployment_assessment_id: - raise ValueError( - "DeploymentAssessment deterministic identity does not match content" - ) - - def validate_deployment_authorization_identity( authorization: DeploymentAuthorization, ) -> None: From 945eb1167013f055d5383477af60cb4ed3427dcd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:38:05 +1000 Subject: [PATCH 034/616] refactor(deployment): remove assessment export --- semapact/deployment/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index 8fce04af..53d65269 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -6,7 +6,6 @@ from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, - DeploymentAssessment, DeploymentAuthorization, DeploymentPlan, DeploymentPreview, @@ -30,7 +29,6 @@ "DeploymentAction", "DeploymentActionKind", "DeploymentAdapter", - "DeploymentAssessment", "DeploymentAuthorization", "DeploymentExecutionConfig", "DeploymentOrchestrator", From 7b596443070953455693775d3e5fe63d5917da7a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:38:29 +1000 Subject: [PATCH 035/616] feat(cli): emit immutable deployment bundle for CI handoff --- .../interfaces/commands/deployment_cmd.py | 95 ++++++++++++------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index fec0d44c..64c0e6cd 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -12,8 +12,9 @@ from pydantic import BaseModel from pydantic import ValidationError as PydanticValidationError +from semapact.application.models.deployment_workflow import DeploymentBundle from semapact.application.services.deployment import DeploymentService -from semapact.contractops import AppliedContractRelease +from semapact.contractops import AppliedContractRelease, ReleaseSnapshot from semapact.deployment import ( DeploymentAuthorization, DeploymentPlan, @@ -41,7 +42,7 @@ class DeploymentCommandResult: def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: - """Assess one candidate contract against fresh runtime without execution authority.""" + """Build one immutable CI/manual deployment bundle without runtime mutation.""" from semapact.application.services.deployment_workflow import ( DeploymentWorkflowService, ) @@ -75,7 +76,7 @@ def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: location.platform, contract_server=location.contract_server, ) - result = DeploymentWorkflowService().assess( + bundle = DeploymentWorkflowService().assess( base_contract, candidate_contract, effective_date=args.effective_date, @@ -85,32 +86,26 @@ def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: target=target, adapter=adapter, ) + if args.bundle_out: + _write_model_artifact(args.bundle_out, bundle) + gate = evaluate_governance_gate( - result.release.decision, + bundle.decision, GovernanceOperation.DEPLOY, ) - payload = { - "executable": False, - "governanceDecision": result.release.decision.model_dump(mode="json"), - "changeSet": result.release.change_set.model_dump(mode="json"), - "releasePlan": result.release.release_plan.model_dump(mode="json"), - "versionResolution": result.release.version_resolution.model_dump(mode="json"), - "deploymentAssessment": result.deployment.model_dump(mode="json"), - } rendered = ( - json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True) + _model_json(bundle) if args.output == "json" - else _assessment_text(result) + else _bundle_text(bundle, artifact_path=args.bundle_out) ) return DeploymentCommandResult( output=rendered, outcome=outcome_from_gate_result(gate), ) - def run_deployment_plan(args: argparse.Namespace) -> DeploymentCommandResult: - """Build one provider-neutral DeploymentPlan from an exact applied release.""" - release = _load_model(args.release, AppliedContractRelease) + """Build one provider-neutral DeploymentPlan from an exact release artifact.""" + release = _load_release_artifact(args.release) target = DeploymentTarget( platform=args.platform, runtime_target=args.runtime, @@ -223,6 +218,33 @@ def _load_model(path: str, model_type: type[_ModelT]) -> _ModelT: ) from exc +def _load_release_artifact(path: str) -> ReleaseSnapshot | AppliedContractRelease: + try: + raw = ( + sys.stdin.read() + if path == "-" + else Path(path).read_text(encoding="utf-8") + ) + except OSError as exc: + raise ValidationError(f"Invalid release artifact '{path}': {exc}") from exc + + errors: list[str] = [] + for model_type in (ReleaseSnapshot, AppliedContractRelease): + try: + return model_type.model_validate_json(raw) + except PydanticValidationError as exc: + errors.append(f"{model_type.__name__}: {exc}") + raise ValidationError( + f"Invalid release artifact '{path}': " + " | ".join(errors) + ) + + +def _write_model_artifact(path: str, model: BaseModel) -> None: + artifact_path = Path(path) + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text(_model_json(model) + "\n", encoding="utf-8") + + def _model_json(model: BaseModel) -> str: return json.dumps( model.model_dump(mode="json"), @@ -299,32 +321,37 @@ def _assessment_source_reference(contract_server, cli_source_reference: str | No return host -def _assessment_text(result) -> str: - release = result.release - assessment = result.deployment +def _bundle_text( + bundle: DeploymentBundle, + *, + artifact_path: str | None, +) -> str: + plan = bundle.deployment_plan + preview = bundle.review_preview lines = [ - f"Contract: {assessment.contract_id}@{assessment.candidate_version}", - f"Governance: {release.decision.decision.value}", - f"Required bump: {release.decision.required_version_bump}", - ( - f"Target: {assessment.target.platform}/" - f"{assessment.target.runtime_target}" - ), - "Executable: no (read-only assessment)", - "Operations:", + f"Contract: {bundle.release_snapshot.contract_id}@" + f"{bundle.release_snapshot.selected_version}", + f"Governance: {bundle.decision.decision.value}", + f"Required bump: {bundle.decision.required_version_bump}", + f"Target: {plan.target.platform}/{plan.target.runtime_target}", + f"Deployment plan: {plan.deployment_plan_id}", + f"Bundle digest: {bundle.bundle_digest}", + "Execution authority: none (CI/read-only bundle)", + "Review operations:", ] - for operation in assessment.operations: + for operation in preview.operations: detail = f" - {operation.kind.value} {operation.governed_asset}" if operation.statement is not None: detail += f": {operation.statement}" lines.append(detail) - if not assessment.operations: + if not preview.operations: lines.append(" - none") lines.extend( [ - f"Observation source: {assessment.source_identifier}", - f"Observation fingerprint: {assessment.observation_fingerprint}", - f"Assessment id: {assessment.deployment_assessment_id}", + f"Observation source: {preview.source_identifier}", + f"Observation fingerprint: {preview.observation_fingerprint}", ] ) + if artifact_path: + lines.append(f"Bundle artifact: {artifact_path}") return "\n".join(lines) From 41a7357709017266939a7b06e7e5fa6e042eb598 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:38:42 +1000 Subject: [PATCH 036/616] feat(cli): add deployment bundle artifact output --- semapact/interfaces/cli.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index 3f21f8f9..41e682d8 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -478,6 +478,10 @@ def _build_parser() -> argparse.ArgumentParser: help="Fallback stable runtime source identity when the candidate defines no server host", ) deployment_assess_parser.add_argument("--runtime-context", default="auto") + deployment_assess_parser.add_argument( + "--bundle-out", + help="Write the immutable DeploymentBundle JSON artifact to this path", + ) deployment_assess_parser.add_argument( "--output", choices=["text", "json"], @@ -487,7 +491,7 @@ def _build_parser() -> argparse.ArgumentParser: _add_effective_date_argument(deployment_assess_parser) deployment_plan_parser = deployment_subparsers.add_parser( - "plan", help="Build a DeploymentPlan from an exact AppliedContractRelease" + "plan", help="Build a DeploymentPlan from an exact ReleaseSnapshot or legacy applied release" ) deployment_plan_parser.add_argument("--release", required=True) deployment_plan_parser.add_argument("--platform", required=True) From 194ba525d1d3cf4e2e24ef84a5f4e353aea9fa42 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:38:57 +1000 Subject: [PATCH 037/616] test(application): cover immutable deployment bundle --- tests/test_deployment_workflow_service.py | 118 +++++++++++++--------- 1 file changed, 72 insertions(+), 46 deletions(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 7c18b1fa..1397239f 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -11,31 +11,31 @@ from semapact.application.services.deployment_workflow import DeploymentWorkflowService from semapact.deployment import ( DeploymentAdapter, - DeploymentAssessment, + DeploymentPreview, DeploymentTarget, NativeOperation, NativeOperationKind, ) -from semapact.deployment.models import compute_deployment_assessment_id -from semapact.observation import ( - ObservedPlatformState, - with_observed_state_fingerprint, -) +from semapact.deployment.models import compute_deployment_preview_id +from semapact.observation import ObservedPlatformState, with_observed_state_fingerprint from semapact.reconciliation import ReconciliationResult -class _AssessmentAdapter(DeploymentAdapter): +class _PreviewAdapter(DeploymentAdapter): key = "fake" def __init__(self) -> None: - self.calls = 0 + self.preview_calls = 0 + + def validate(self, plan) -> None: + pass - def assess(self, contract, *, candidate_revision_ref, target): - self.calls += 1 + def preview(self, plan) -> DeploymentPreview: + self.preview_calls += 1 observation = with_observed_state_fingerprint( ObservedPlatformState( platform="fake", - source_identifier=target.source_reference, + source_identifier=plan.target.source_reference, assets=(), captured_at=datetime(2026, 9, 20, tzinfo=timezone.utc), ) @@ -48,37 +48,28 @@ def assess(self, contract, *, candidate_revision_ref, target): statement="CREATE orders", ), ) - assessment_id = compute_deployment_assessment_id( - contract_id=str(contract.id), - candidate_revision_ref=candidate_revision_ref, - candidate_version=str(contract.version), - target=target, - source_identifier=observation.source_identifier, - observation_fingerprint=observation.fingerprint, - operations=operations, - ) - return DeploymentAssessment( - deployment_assessment_id=assessment_id, - contract_id=str(contract.id), - candidate_revision_ref=candidate_revision_ref, - candidate_version=str(contract.version), - target=target, + return DeploymentPreview( + deployment_preview_id=compute_deployment_preview_id( + deployment_plan_id=plan.deployment_plan_id, + platform="fake", + runtime_target=plan.target.runtime_target, + source_identifier=observation.source_identifier, + observation_fingerprint=observation.fingerprint, + operations=operations, + ), + deployment_plan_id=plan.deployment_plan_id, + platform="fake", + runtime_target=plan.target.runtime_target, source_identifier=observation.source_identifier, observation_fingerprint=observation.fingerprint, operations=operations, ) - def validate(self, plan): - raise AssertionError("assessment must not create or validate a DeploymentPlan") - - def preview(self, plan): - raise AssertionError("assessment must not create a DeploymentPreview") - - def execute(self, plan, preview, authorization): - raise AssertionError("assessment must not execute") + def execute(self, plan, preview, authorization) -> None: + raise AssertionError("CI bundle construction must not execute") def verify(self, plan) -> ReconciliationResult: - raise AssertionError("assessment must not verify a deployment") + raise AssertionError("CI bundle construction must not verify deployment") def _contract(*, name: str) -> OpenDataContractStandard: @@ -105,15 +96,15 @@ def _contract(*, name: str) -> OpenDataContractStandard: ) -def test_workflow_assessment_composes_release_planning_and_runtime_assessment() -> None: - adapter = _AssessmentAdapter() +def test_workflow_assessment_builds_content_addressed_ci_bundle() -> None: + adapter = _PreviewAdapter() target = DeploymentTarget( platform="fake", runtime_target="main.sales", source_reference="runtime:test", ) - result = DeploymentWorkflowService().assess( + bundle = DeploymentWorkflowService().assess( _contract(name="Orders old"), _contract(name="Orders new"), effective_date="2026-09-20", @@ -123,11 +114,46 @@ def test_workflow_assessment_composes_release_planning_and_runtime_assessment() adapter=adapter, ) - assert adapter.calls == 1 - assert result.release.change_set.base_revision_ref == "git:base" - assert result.release.release_plan.release_revision_ref == "git:candidate" - assert result.deployment.candidate_revision_ref == "git:candidate" - assert result.deployment.contract_id == result.release.release_plan.contract_id - assert result.deployment.operations[0].kind is NativeOperationKind.CREATE - assert not hasattr(result.deployment, "applied_release_id") - assert not hasattr(result.deployment, "deployment_plan_id") + assert adapter.preview_calls == 1 + assert bundle.change_set.base_revision_ref == "git:base" + assert bundle.release_plan.release_revision_ref == "git:candidate" + assert bundle.release_snapshot.release_revision_ref == "git:candidate" + assert ( + bundle.deployment_plan.release_id + == bundle.release_snapshot.release_snapshot_id + ) + assert bundle.deployment_plan.plan_version == "3" + assert bundle.review_preview.deployment_plan_id == bundle.deployment_plan.deployment_plan_id + assert bundle.review_preview.operations[0].kind is NativeOperationKind.CREATE + assert bundle.bundle_digest.startswith("sha256:") + assert not hasattr(bundle, "authorization") + assert not hasattr(bundle.release_snapshot, "authorization_id") + + +def test_same_inputs_produce_same_bundle_digest() -> None: + target = DeploymentTarget( + platform="fake", + runtime_target="main.sales", + source_reference="runtime:test", + ) + first = DeploymentWorkflowService().assess( + _contract(name="Orders old"), + _contract(name="Orders new"), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=target, + adapter=_PreviewAdapter(), + ) + second = DeploymentWorkflowService().assess( + _contract(name="Orders old"), + _contract(name="Orders new"), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=target, + adapter=_PreviewAdapter(), + ) + + assert first.bundle_digest == second.bundle_digest + assert first == second From e96a866a10d2dc3aad3888960634f976c01d5c2f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:39:07 +1000 Subject: [PATCH 038/616] test(application): remove duplicate assessment service path --- tests/test_deployment_service.py | 78 +------------------------------- 1 file changed, 2 insertions(+), 76 deletions(-) diff --git a/tests/test_deployment_service.py b/tests/test_deployment_service.py index ff188881..257106d4 100644 --- a/tests/test_deployment_service.py +++ b/tests/test_deployment_service.py @@ -4,7 +4,6 @@ import pytest from open_data_contract_standard.model import ( - OpenDataContractStandard, SchemaObject, SchemaProperty, ) @@ -13,7 +12,6 @@ DeploymentAction, DeploymentAdapter, DeploymentActionKind, - DeploymentAssessment, DeploymentAuthorization, DeploymentPlan, DeploymentPreview, @@ -22,7 +20,6 @@ NativeOperationKind, ) from semapact.deployment.models import ( - compute_deployment_assessment_id, compute_deployment_authorization_id, compute_deployment_plan_id, compute_deployment_preview_id, @@ -43,22 +40,14 @@ def __init__( self, preview: DeploymentPreview, verification: ReconciliationResult, - assessment: DeploymentAssessment | None = None, ) -> None: self.preview_result = preview self.verification_result = verification - self.assessment_result = assessment - self.assess_calls = 0 self.preview_calls = 0 self.verify_calls = 0 self.execute_calls = 0 self.executed = None - def assess(self, contract, *, candidate_revision_ref, target): - self.assess_calls += 1 - assert self.assessment_result is not None - return self.assessment_result - def validate(self, plan: DeploymentPlan) -> None: pass @@ -174,44 +163,6 @@ def _adapter(plan: DeploymentPlan) -> FakeDeploymentAdapter: return FakeDeploymentAdapter( _preview(plan), _verification(plan), - _assessment(plan), - ) - - -def _candidate_contract() -> OpenDataContractStandard: - return OpenDataContractStandard.model_construct( - id="orders-contract", - version="1.2.3", - schema_=[], - servers=[], - ) - - -def _assessment(plan: DeploymentPlan) -> DeploymentAssessment: - observation = _observation() - operation = NativeOperation( - kind=NativeOperationKind.NO_OP, - governed_asset="orders", - ) - assert observation.fingerprint is not None - assessment_id = compute_deployment_assessment_id( - contract_id="orders-contract", - candidate_revision_ref="candidate:abc", - candidate_version="1.2.3", - target=plan.target, - source_identifier=observation.source_identifier, - observation_fingerprint=observation.fingerprint, - operations=(operation,), - ) - return DeploymentAssessment( - deployment_assessment_id=assessment_id, - contract_id="orders-contract", - candidate_revision_ref="candidate:abc", - candidate_version="1.2.3", - target=plan.target, - source_identifier=observation.source_identifier, - observation_fingerprint=observation.fingerprint, - operations=(operation,), ) @@ -233,23 +184,6 @@ def _authorization(plan: DeploymentPlan) -> DeploymentAuthorization: -def test_assess_delegates_candidate_to_unified_adapter_entrypoint() -> None: - plan = _plan() - adapter = _adapter(plan) - - result = DeploymentService().assess( - _candidate_contract(), - candidate_revision_ref="candidate:abc", - target=plan.target, - adapter=adapter, - ) - - assert result == adapter.assessment_result - assert adapter.assess_calls == 1 - assert adapter.preview_calls == 0 - assert adapter.execute_calls == 0 - - def test_preview_delegates_to_unified_adapter_entrypoint() -> None: plan = _plan() adapter = _adapter(plan) @@ -288,21 +222,14 @@ def test_verify_delegates_to_same_adapter_entrypoint() -> None: assert adapter.verify_calls == 1 -@pytest.mark.parametrize("operation", ["assess", "preview", "verify", "execute"]) +@pytest.mark.parametrize("operation", ["preview", "verify", "execute"]) def test_service_rejects_adapter_platform_mismatch(operation: str) -> None: plan = _plan() adapter = _adapter(plan) adapter.key = "snowflake" with pytest.raises(ValidationError, match="Deployment adapter does not match"): - if operation == "assess": - DeploymentService().assess( - _candidate_contract(), - candidate_revision_ref="candidate:abc", - target=plan.target, - adapter=adapter, - ) - elif operation == "preview": + if operation == "preview": DeploymentService().preview(plan, adapter=adapter) elif operation == "verify": DeploymentService().verify(plan, adapter=adapter) @@ -314,7 +241,6 @@ def test_service_rejects_adapter_platform_mismatch(operation: str) -> None: adapter=adapter, ) - assert adapter.assess_calls == 0 assert adapter.preview_calls == 0 assert adapter.verify_calls == 0 assert adapter.execute_calls == 0 From 17282e3a2e44d900da2e9f31906a08f298c7fa87 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:39:19 +1000 Subject: [PATCH 039/616] test(deployment): remove duplicate assessment path coverage --- tests/test_deployment_orchestrator.py | 76 +-------------------------- 1 file changed, 1 insertion(+), 75 deletions(-) diff --git a/tests/test_deployment_orchestrator.py b/tests/test_deployment_orchestrator.py index 5eb4ce8a..3992e904 100644 --- a/tests/test_deployment_orchestrator.py +++ b/tests/test_deployment_orchestrator.py @@ -4,23 +4,17 @@ import pytest -from open_data_contract_standard.model import ( - OpenDataContractStandard, - SchemaObject, - SchemaProperty, -) +from open_data_contract_standard.model import SchemaObject, SchemaProperty from semapact.deployment.compilers import TransitionCompiler from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, - DeploymentAssessment, DeploymentAuthorization, DeploymentPlan, DeploymentTarget, NativeOperation, NativeOperationKind, - compute_deployment_assessment_id, compute_deployment_authorization_id, compute_deployment_plan_id, ) @@ -179,74 +173,6 @@ def _observation() -> ObservedPlatformState: -def _candidate_contract() -> OpenDataContractStandard: - return OpenDataContractStandard.model_construct( - id="contract-1", - version="1.0.0", - schema_=[ - SchemaObject( - name="orders", - physicalName="orders", - physicalType="table", - properties=[ - SchemaProperty( - name="id", - logicalType="integer", - physicalType="BIGINT", - required=False, - ) - ], - ) - ], - servers=[], - ) - - -def test_generic_orchestrator_assesses_candidate_without_execution_authority() -> None: - runtime_provider = _RuntimeProvider(_observation()) - orchestrator = DeploymentOrchestrator( - runtime_provider=runtime_provider, - schema_mapper=PassThroughSchemaMapper(), - transition_planner=AdditiveSchemaTransitionPlanner(), - transition_compiler=_Compiler(), - executor=_Executor(), - ) - target = DeploymentTarget( - platform="fake", - runtime_target="main", - source_reference="source-1", - ) - - assessment = orchestrator.assess( - _candidate_contract(), - candidate_revision_ref="revision:candidate", - target=target, - ) - - assert isinstance(assessment, DeploymentAssessment) - assert assessment.contract_id == "contract-1" - assert assessment.candidate_revision_ref == "revision:candidate" - assert assessment.operations == ( - NativeOperation( - kind=NativeOperationKind.CREATE, - governed_asset="orders", - statement="CREATE_ASSET orders", - ), - ) - assert assessment.deployment_assessment_id == compute_deployment_assessment_id( - contract_id=assessment.contract_id, - candidate_revision_ref=assessment.candidate_revision_ref, - candidate_version=assessment.candidate_version, - target=assessment.target, - source_identifier=assessment.source_identifier, - observation_fingerprint=assessment.observation_fingerprint, - operations=assessment.operations, - ) - assert not hasattr(assessment, "applied_release_id") - assert not hasattr(assessment, "deployment_plan_id") - assert runtime_provider.observe_calls == 1 - - def test_generic_orchestrator_owns_observe_preview_freshness_and_execute() -> None: plan = _plan() runtime_provider = _RuntimeProvider(_observation()) From 5daca3183b5c663af60888229274e0b1a4ed77db Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:39:35 +1000 Subject: [PATCH 040/616] test(cli): cover deployment bundle artifact output --- tests/interfaces/test_deployment_cmd.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index 2326dda0..d4c97381 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -93,6 +93,8 @@ def test_deployment_parser_exposes_read_only_assessment_and_explicit_phases() -> "2026-09-20", "--server", "production", + "--bundle-out", + "bundle.json", "--output", "json", ] @@ -133,6 +135,7 @@ def test_deployment_parser_exposes_read_only_assessment_and_explicit_phases() -> assert assess.deployment_command == "assess" assert assess.output == "json" assert assess.server == "production" + assert assess.bundle_out == "bundle.json" assert plan.deployment_command == "plan" assert plan.source_reference == SOURCE_REFERENCE assert preview.deployment_command == "preview" @@ -168,7 +171,7 @@ def test_plan_command_outputs_canonical_deployment_plan(tmp_path) -> None: payload = json.loads(result.output) assert result.outcome is ProcessOutcome.SUCCESS - assert payload["applied_release_id"] == release.applied_release_id + assert payload["release_id"] == release.applied_release_id assert payload["target"] == { "platform": "databricks", "runtime_target": "main.silver", From 7f8814d2d3a290d6191b505be14547fc52a355dd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:39:42 +1000 Subject: [PATCH 041/616] test(contractops): prove pure release snapshot construction --- tests/test_contractops_execution.py | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_contractops_execution.py b/tests/test_contractops_execution.py index 164bf309..4872d4b7 100644 --- a/tests/test_contractops_execution.py +++ b/tests/test_contractops_execution.py @@ -18,6 +18,7 @@ authorize_contract_operation, build_change_set_from_decision, build_release_plan, + build_release_snapshot, publish_contract_release, resolve_release_version, ) @@ -145,6 +146,36 @@ def publish(self, release) -> str: return f"published:{release.applied_release_id}" +def test_release_snapshot_is_pure_and_requires_no_apply_authorization() -> None: + candidate, decision, change_set, release_plan, version_resolution = _release_context( + "review" + ) + + first = build_release_snapshot( + candidate, + candidate_revision_ref="rev:candidate", + decision=decision, + change_set=change_set, + release_plan=release_plan, + version_resolution=version_resolution, + ) + second = build_release_snapshot( + candidate, + candidate_revision_ref="rev:candidate", + decision=decision, + change_set=change_set, + release_plan=release_plan, + version_resolution=version_resolution, + ) + + assert first == second + assert first.release_snapshot_id == second.release_snapshot_id + assert first.selected_version == "1.1.0" + assert first.to_contract().version == "1.1.0" + assert candidate.version == "1.0.0" + assert not hasattr(first, "authorization_id") + + def test_apply_metadata_only_release_uses_selected_patch_without_mutating_candidate() -> None: candidate, decision, change_set, release_plan, version_resolution = _release_context( "allow" From e6a7b7b8a3740f774b195bc621e437fad47ae448 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:39:51 +1000 Subject: [PATCH 042/616] test(deployment): build v3 plan from release snapshot --- tests/test_deployment_plan.py | 39 +++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/tests/test_deployment_plan.py b/tests/test_deployment_plan.py index f044f8ed..44158f56 100644 --- a/tests/test_deployment_plan.py +++ b/tests/test_deployment_plan.py @@ -10,8 +10,11 @@ ) from pydantic import ValidationError as PydanticValidationError -from semapact.contractops import AppliedContractRelease -from semapact.contractops.integrity import compute_applied_release_id +from semapact.contractops import AppliedContractRelease, ReleaseSnapshot +from semapact.contractops.integrity import ( + compute_applied_release_id, + compute_release_snapshot_id, +) from semapact.deployment import ( DeploymentAction, DeploymentActionKind, @@ -75,6 +78,27 @@ def _release( ) +def _snapshot( + *, + schemas: list[SchemaObject] | None = None, +) -> ReleaseSnapshot: + applied = _release(schemas=schemas) + fields = { + "contract_id": applied.contract_id, + "decision_id": applied.decision_id, + "change_set_id": applied.change_set_id, + "release_plan_id": applied.release_plan_id, + "version_resolution_id": applied.version_resolution_id, + "release_revision_ref": applied.release_revision_ref, + "selected_version": applied.selected_version, + "released_contract_json": applied.released_contract_json, + } + return ReleaseSnapshot( + release_snapshot_id=compute_release_snapshot_id(**fields), + **fields, + ) + + def _target() -> DeploymentTarget: return DeploymentTarget( platform="Databricks", @@ -84,6 +108,17 @@ def _target() -> DeploymentTarget: ) +def test_release_snapshot_produces_v3_plan_without_apply_authorization() -> None: + snapshot = _snapshot() + + plan = build_deployment_plan(snapshot, _target()) + + assert plan.release_id == snapshot.release_snapshot_id + assert plan.plan_version == "3" + assert plan.contract_id == snapshot.contract_id + assert plan.selected_version == snapshot.selected_version + + def test_same_exact_release_and_target_produce_same_plan() -> None: release = _release(schemas=[_schema("zeta"), _schema("alpha")]) From a6a5089f996ef258d47322235804c1be95f3c324 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:40:34 +1000 Subject: [PATCH 043/616] docs(deployment): define CI deployment bundle boundary --- docs/deployment_plans.md | 45 ++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 4cb5e248..7d6708ef 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -5,8 +5,8 @@ SemaPact treats a released ODCS contract as governed desired state, not as an ex The released contract is the authoritative artifact. SemaPact does **not** require a separate DDL build artifact before deployment. SQL and other provider-native commands are derived only after an exact released desired state is compared with an exact runtime target. ```text -AppliedContractRelease - = governed desired state +ReleaseSnapshot + = exact governed desired state + ObservedPlatformState = point-in-time runtime state @@ -23,7 +23,7 @@ This matters because one release may require different native operations in diff The deployment planning boundary is therefore: ```text -AppliedContractRelease +ReleaseSnapshot + DeploymentTarget ↓ DeploymentPlan @@ -55,9 +55,9 @@ The orchestration above is provider-neutral. Platform packages configure or impl ## What a DeploymentPlan means -A `DeploymentPlan` is a deterministic, provider-neutral statement of the runtime state that an exact applied contract release intends to converge toward. +A `DeploymentPlan` is a deterministic, provider-neutral statement of the runtime state that one exact release snapshot intends to converge toward. -It is built only from `AppliedContractRelease`; drafts and raw candidate contracts are not deployment authority. +The canonical CI path builds it from a pure `ReleaseSnapshot`, which freezes the selected contract revision/version without requiring side-effect authorization. Legacy `AppliedContractRelease` inputs remain readable for v2-plan compatibility, but new CI bundles use v3 plans bound to `releaseId`. The initial action vocabulary deliberately contains only: @@ -144,26 +144,34 @@ Preview, execution, and verification fail closed when fresh runtime evidence com The plan does not contain credentials, workspace clients, SQL connections, or provider sessions. -## Candidate assessment before approval +## CI assessment and the DeploymentBundle boundary -A Data Engineer or CI job can assess a proposed contract revision against fresh runtime state before any release or deployment authority exists: +CI must produce a stable artifact that CD can consume after approval. SemaPact packages the exact planning material into a content-addressed `DeploymentBundle`: ```text -base contract + candidate contract - ↓ +base + candidate + ↓ GovernanceDecision → ChangeSet → ReleasePlan → VersionResolution +→ ReleaseSnapshot +→ DeploymentPlan + fresh runtime observation ↓ -DeploymentAssessment +DeploymentPreview # review evidence only + ↓ +DeploymentBundle + ↓ +sha256 content digest ``` -`DeploymentAssessment` is deliberately non-executable. It binds the candidate revision, deployment target, fresh observation fingerprint, and provider-native CREATE / ALTER / NO_OP operations for review, but it contains no `AppliedContractRelease`, `DeploymentPlan`, or authorization identity. +`DeploymentBundle` is a transport and integrity boundary, not another governance authority. It contains the canonical decision/planning artifacts, the exact release snapshot, the target-specific deployment plan, and the runtime preview that CI showed to reviewers. -The assessment path reuses the same schema mapper, comparator, transition planner, and provider compiler as executable deployment preview. Approval does not promote the assessment itself into execution authority; post-approval deployment builds the canonical release/deployment artifacts and obtains fresh runtime evidence again. +The bundle contains **no execution authorization**. The CI preview is also not a future SQL script: CD must re-observe runtime and re-derive provider operations at the mutation boundary. The bundle digest lets CI publish one immutable artifact and lets approval/CD pin the exact reviewed material. + +The previous candidate-specific `DeploymentAssessment` artifact is intentionally not part of this model. CI uses the same canonical `DeploymentPlan → preview` path as later deployment rather than maintaining a second desired-vs-runtime workflow. ## CLI workflow @@ -179,18 +187,19 @@ semapact deployment assess \ --candidate-revision-ref git:def456 \ --effective-date 2026-09-20 \ --server production \ - --output json + --bundle-out ./artifacts/orders-prod.bundle.json \ + --output text ``` When the candidate contract defines the selected server, its platform, catalog/schema target, and host are authoritative. For contracts without servers, provide `--platform`, `--runtime`, and `--source-reference`. -The JSON output contains canonical release-planning artifacts plus `deploymentAssessment` and explicitly reports `"executable": false`. This surface is suitable for local review and CI; it does not create runtime mutation authority. +The bundle file contains the canonical planning artifacts, `ReleaseSnapshot`, v3 `DeploymentPlan`, and CI-time `reviewPreview`, protected by `bundleDigest`. Use `--output json` to emit that same bundle on stdout instead. CI can publish the file using its normal pipeline-artifact mechanism; SemaPact does not couple this package to one CI vendor. ### Plan ```bash semapact deployment plan \ - --release ./artifacts/applied-release.json \ + --release ./artifacts/release-snapshot.json \ --platform databricks \ --runtime main.sales \ --source-reference https://dbc-example.cloud.databricks.com @@ -288,14 +297,14 @@ A PUBLISH authorization cannot authorize DEPLOY. `deploymentPlanId` is UUID5-derived from the full stable plan record: -- exact `AppliedContractRelease` identity and provenance; +- exact release identity and provenance (`ReleaseSnapshot` for v3 plans; legacy applied-release identity for v2 compatibility); - exact deployment target, including runtime source reference; - canonical actions ordered by governed asset identity; - plan schema version. -The same exact applied release and target therefore produce the same DeploymentPlan. +The same exact release and target therefore produce the same DeploymentPlan. -Action ordering is canonical even when schemas appear in a different order in source ODCS. However, DeploymentPlan does not redefine release identity: two distinct `AppliedContractRelease` artifacts remain distinct authorities even if their projected actions happen to be equivalent. +Action ordering is canonical even when schemas appear in a different order in source ODCS. However, DeploymentPlan does not redefine release identity: two distinct exact release artifacts remain distinct plan inputs even if their projected actions happen to be equivalent. `DeploymentPreview` is likewise deterministic for the same plan and observed runtime evidence, but deterministic IDs provide artifact consistency rather than cryptographic authenticity. Execution still validates exact binding and fresh runtime evidence at the side-effect boundary. From c281bf339bc767aabe231db38611e764ac3aa8cc Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:40:47 +1000 Subject: [PATCH 044/616] docs(readme): document CI deployment bundle handoff --- README.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index eacda50c..e763e7e5 100644 --- a/README.md +++ b/README.md @@ -74,11 +74,11 @@ GovernanceDecision → ChangeSet → ReleasePlan → VersionResolution -→ ContractOpsAuthorization -→ AppliedContractRelease +→ ReleaseSnapshot → DeploymentPlan -→ DeploymentAuthorization -→ runtime mutation +→ DeploymentBundle (CI artifact) + +Approval / authorization remains a separate side-effect boundary before runtime mutation. ``` For production assurance: @@ -183,8 +183,9 @@ See [`docs/contractops_phases.md`](docs/contractops_phases.md) and [`docs/versio ```text assess -→ base/candidate governance + fresh runtime DeploymentAssessment -→ read-only / non-executable +→ governance + ReleaseSnapshot + DeploymentPlan + fresh review preview +→ content-addressed DeploymentBundle +→ read-only / no execution authority plan → DeploymentPlan @@ -202,7 +203,7 @@ verify → IN_SYNC / DRIFT / INDETERMINATE ``` -Planning and preview are read-only. Runtime mutation occurs only through `deployment execute`, and provider execution success is not treated as convergence proof. +Assessment, planning, and preview are read-only. The CI assessment path writes a content-addressed `DeploymentBundle` that can be published as a pipeline artifact for later CD consumption. Runtime mutation occurs only through the execution boundary, and provider execution success is not treated as convergence proof. The first Databricks write capability is intentionally narrow: create a missing managed Delta table, add missing nullable governed columns to an existing managed table, or perform NO_OP when the governed shape is already satisfied. Rename, existing-column type/nullability mutation, required-column addition without a safe migration strategy, DROP, and existing external/non-managed asset mutation fail closed. @@ -342,10 +343,13 @@ semapact deployment assess \ --base-revision-ref git:abc123 \ --candidate-revision-ref git:def456 \ --effective-date 2026-09-20 \ - --server production + --server production \ + --bundle-out ./artifacts/orders-prod.bundle.json ``` -After an exact `AppliedContractRelease` and deployment authorization have been produced, the canonical runtime path remains: +The bundle contains the exact reviewed `ReleaseSnapshot`, v3 `DeploymentPlan`, CI-time review preview, and a SHA-256 content digest. CI publishes this file using its normal artifact mechanism; the preview remains review evidence only and must be refreshed at CD execution time. + +Low-level deployment commands remain available for explicit artifact workflows and compatibility: ```bash semapact deployment plan --help From bb3ac511c81c25c33a2f887fc99321254d2059a4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:41:27 +1000 Subject: [PATCH 045/616] fix(ci): allow review-required deployment bundle assessment --- semapact/interfaces/commands/deployment_cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 64c0e6cd..29ede1f1 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -91,7 +91,7 @@ def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: gate = evaluate_governance_gate( bundle.decision, - GovernanceOperation.DEPLOY, + GovernanceOperation.PROPOSE, ) rendered = ( _model_json(bundle) From 517ac72ae9b48fff443ea42ec5e1dbc637e993d9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:41:53 +1000 Subject: [PATCH 046/616] style(contractops): remove redundant snapshot validation --- semapact/contractops/execution.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/semapact/contractops/execution.py b/semapact/contractops/execution.py index a0f7ef3d..46ebda52 100644 --- a/semapact/contractops/execution.py +++ b/semapact/contractops/execution.py @@ -13,14 +13,11 @@ ReleaseSnapshot, ) from semapact.contractops.integrity import ( - SEMAPACT_APPLIED_RELEASE_NAMESPACE, - SEMAPACT_PUBLICATION_NAMESPACE, compute_applied_release_id, compute_release_snapshot_id, compute_publication_id, validate_applied_release_identity, validate_contractops_authorization_identity, - validate_release_snapshot_identity, ) from semapact.contractops.models import ( ChangeSet, @@ -107,7 +104,7 @@ def build_release_snapshot( selected_version=selected_version, released_contract_json=released_contract_json, ) - snapshot = ReleaseSnapshot( + return ReleaseSnapshot( release_snapshot_id=release_snapshot_id, contract_id=release_plan.contract_id, decision_id=decision.decision_id, @@ -118,8 +115,6 @@ def build_release_snapshot( selected_version=selected_version, released_contract_json=released_contract_json, ) - validate_release_snapshot_identity(snapshot) - return snapshot def apply_contract_release( From c5e6d3017d27ae4905fea3d3824e8a316f7a93e9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:42:09 +1000 Subject: [PATCH 047/616] docs(contractops): make release snapshot the pure planning boundary --- docs/contractops_phases.md | 54 ++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/docs/contractops_phases.md b/docs/contractops_phases.md index 5160c859..a5b6afa2 100644 --- a/docs/contractops_phases.md +++ b/docs/contractops_phases.md @@ -12,19 +12,21 @@ PLAN → ChangeSet → ReleasePlan → VersionResolution +→ ReleaseSnapshot -AUTHORIZE -→ ContractOpsAuthorization +CI DEPLOYMENT PLANNING +→ DeploymentPlan +→ DeploymentPreview +→ DeploymentBundle -APPLY -→ AppliedContractRelease +AUTHORIZE +→ operation-scoped authorization -PUBLISH -→ PublicationResult +PUBLISH / DEPLOY +→ external side effects -DEPLOY -→ DeploymentPlan + DeploymentAuthorization -→ runtime mutation through a platform adapter +Legacy APPLY compatibility +→ AppliedContractRelease ``` Each phase consumes artifacts from the previous phases. Later phases do not recalculate earlier decisions. @@ -37,11 +39,13 @@ The governed contract remains the model of desired state throughout the lifecycl ```text candidate ODCS - ↓ ANALYZE / PLAN / AUTHORIZE / APPLY -AppliedContractRelease -= immutable governed desired state - ↓ DEPLOY planning against one runtime target -provider-native operations + ↓ ANALYZE / PLAN +ReleaseSnapshot += immutable selected governed desired state + ↓ target-specific DeploymentPlan +fresh runtime observation + ↓ +provider-native preview operations ``` A SQL or provider-specific export is a **derived compilation output**, not a second source of truth and not deployment authority. It may be regenerated from the exact governed contract state whenever required. @@ -54,7 +58,7 @@ base contract ↔ candidate contract → governance changes, breaking classification, version requirements Runtime deployment comparison -AppliedContractRelease ↔ observed runtime state +ReleaseSnapshot / DeploymentPlan ↔ observed runtime state → CREATE / ALTER / NO_OP or an explicit unsupported transition ``` @@ -140,24 +144,28 @@ scopeReference? `GovernanceDecision(REVIEW)` remains `REVIEW` after approval. Matching explicit review evidence produces an allowed `ContractOpsAuthorization`; it does not rewrite governance history. -APPLY, PUBLISH, and DEPLOY are distinct operation scopes. Runtime DEPLOY additionally binds authorization to the exact `DeploymentPlan` before mutation. +PUBLISH and DEPLOY are the external side-effect scopes that matter to the new workflow. The historical APPLY authorization remains supported for compatibility while callers migrate to pure `ReleaseSnapshot` materialization. Runtime DEPLOY remains scoped to the exact `DeploymentPlan` before mutation. -## APPLY +## RELEASE SNAPSHOT -APPLY materializes the exact released ODCS state from the planned candidate and `VersionResolution.selectedVersion`. +Release snapshot construction is pure. It materializes the exact selected ODCS state from the planned candidate and `VersionResolution.selectedVersion` without crossing an external side-effect boundary. -The canonical APPLY path: +`build_release_snapshot(...)`: -- requires an allowed `ContractOpsAuthorization(operation=APPLY)`; - requires the supplied candidate revision reference to match the planned revision; - validates contract identity and the expected current version; - copies the candidate and synchronizes only the selected release version; - does not mutate the input candidate; -- does not rerun diffing, lifecycle policy, breaking-change classification, or version authority. +- does not rerun governance or version authority; +- produces a deterministic `ReleaseSnapshot` with no authorization ID. + +This pure snapshot is what CI uses to build a v3 `DeploymentPlan` and content-addressed `DeploymentBundle` before human approval. + +## APPLY compatibility -The output is an immutable `AppliedContractRelease` containing provenance IDs and a canonical JSON snapshot of the released ODCS state. Consumers can materialize a fresh ODCS model from that snapshot. +`apply_contract_release(...)` remains available for existing callers. It validates the historical `ContractOpsAuthorization(operation=APPLY)`, builds the same pure `ReleaseSnapshot`, and wraps that snapshot into the legacy `AppliedContractRelease` artifact. -This also covers metadata-only governed releases: `requiredVersionBump=none` may have been resolved by SemaPact version authority to an actual patch release, and APPLY uses that already-selected version directly. +New CI deployment workflows must not require APPLY authorization merely to plan or preview a deployment. The compatibility wrapper exists to preserve current publication/history integrations while the authorization surface is simplified. ## PUBLISH From babe464fcf0173c5ad34d0c11c38d8e7671ccbf1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:42:32 +1000 Subject: [PATCH 048/616] test(application): fail closed on tampered deployment bundle --- tests/test_deployment_workflow_service.py | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 1397239f..ee568257 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -2,12 +2,16 @@ from datetime import datetime, timezone +import pytest +from pydantic import ValidationError as PydanticValidationError + from open_data_contract_standard.model import ( OpenDataContractStandard, SchemaObject, SchemaProperty, ) +from semapact.application.models.deployment_workflow import DeploymentBundle from semapact.application.services.deployment_workflow import DeploymentWorkflowService from semapact.deployment import ( DeploymentAdapter, @@ -157,3 +161,25 @@ def test_same_inputs_produce_same_bundle_digest() -> None: assert first.bundle_digest == second.bundle_digest assert first == second + + +def test_bundle_rehydration_fails_closed_when_digest_is_tampered() -> None: + target = DeploymentTarget( + platform="fake", + runtime_target="main.sales", + source_reference="runtime:test", + ) + bundle = DeploymentWorkflowService().assess( + _contract(name="Orders old"), + _contract(name="Orders new"), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=target, + adapter=_PreviewAdapter(), + ) + payload = bundle.model_dump(mode="json") + payload["bundle_digest"] = "sha256:" + ("0" * 64) + + with pytest.raises(PydanticValidationError, match="digest does not match"): + DeploymentBundle.model_validate(payload) From 1a1f0d4ad63eaba4467555148f4f54ace23a3e00 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:44:50 +1000 Subject: [PATCH 049/616] fix(deployment): preserve v2 plan serialization --- semapact/deployment/models.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index ffcb7be1..fd8f5c9e 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -13,6 +13,7 @@ ConfigDict, Field, field_validator, + model_serializer, model_validator, ) @@ -165,9 +166,17 @@ def _validate_action_order_and_identity(self) -> DeploymentPlan: validate_deployment_plan_identity(self) return self + @model_serializer(mode="wrap") + def _serialize_release_identity(self, handler): + """Preserve the persisted v2 field name while v3 uses release_id.""" + payload = handler(self) + if self.plan_version == "2": + payload["applied_release_id"] = payload.pop("release_id") + return payload + @property def applied_release_id(self) -> str: - """Compatibility alias for serialized v2 deployment plans.""" + """Compatibility alias for callers using the v2 release field.""" return self.release_id From 5e2c8322ebe1e817172e6edb71dd57bdd538c4f0 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:45:03 +1000 Subject: [PATCH 050/616] test(deployment): lock v2 and v3 release serialization --- tests/test_deployment_plan.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_deployment_plan.py b/tests/test_deployment_plan.py index 44158f56..1214151e 100644 --- a/tests/test_deployment_plan.py +++ b/tests/test_deployment_plan.py @@ -117,6 +117,9 @@ def test_release_snapshot_produces_v3_plan_without_apply_authorization() -> None assert plan.plan_version == "3" assert plan.contract_id == snapshot.contract_id assert plan.selected_version == snapshot.selected_version + payload = plan.model_dump(mode="json") + assert payload["release_id"] == snapshot.release_snapshot_id + assert "applied_release_id" not in payload def test_same_exact_release_and_target_produce_same_plan() -> None: @@ -143,6 +146,9 @@ def test_plan_preserves_exact_applied_release_provenance() -> None: assert plan.selected_version == release.selected_version assert plan.plan_version == "2" assert plan.target.source_reference == "https://workspace.example" + payload = plan.model_dump(mode="json") + assert payload["applied_release_id"] == release.applied_release_id + assert "release_id" not in payload def test_actions_are_provider_neutral_ensure_state_intents() -> None: From 7b683920f871e77f8176cb1f56239786ce716c3f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:45:05 +1000 Subject: [PATCH 051/616] test(cli): preserve legacy plan output field --- tests/interfaces/test_deployment_cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index d4c97381..14f17f08 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -171,7 +171,7 @@ def test_plan_command_outputs_canonical_deployment_plan(tmp_path) -> None: payload = json.loads(result.output) assert result.outcome is ProcessOutcome.SUCCESS - assert payload["release_id"] == release.applied_release_id + assert payload["applied_release_id"] == release.applied_release_id assert payload["target"] == { "platform": "databricks", "runtime_target": "main.silver", From 94cbe97a6685aa7fd5885a4a0b0f7e910770bb72 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:47:18 +1000 Subject: [PATCH 052/616] refactor(deployment): authorize exact release snapshots --- semapact/deployment/authorization.py | 50 +++++++++++++++++----------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index 70f822e0..86569645 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -2,10 +2,11 @@ from __future__ import annotations -from semapact.contractops.execution_models import AppliedContractRelease +from semapact.contractops.execution_models import AppliedContractRelease, ReleaseSnapshot from semapact.contractops.integrity import ( validate_applied_release_identity, validate_contractops_authorization_identity, + validate_release_snapshot_identity, ) from semapact.contractops.models import AuthorizationReason, ContractOpsAuthorization from semapact.deployment.models import ( @@ -20,15 +21,16 @@ def authorize_deployment( plan: DeploymentPlan, - release: AppliedContractRelease, + release: ReleaseSnapshot | AppliedContractRelease, authorization: ContractOpsAuthorization, ) -> DeploymentAuthorization: """Bind one DEPLOY authorization to the exact deployment target/plan.""" if not isinstance(plan, DeploymentPlan): raise TypeError(f"plan must be DeploymentPlan, got {type(plan).__name__}") - if not isinstance(release, AppliedContractRelease): + if not isinstance(release, (ReleaseSnapshot, AppliedContractRelease)): raise TypeError( - f"release must be AppliedContractRelease, got {type(release).__name__}" + "release must be ReleaseSnapshot or AppliedContractRelease, " + f"got {type(release).__name__}" ) if not isinstance(authorization, ContractOpsAuthorization): raise TypeError( @@ -37,7 +39,12 @@ def authorize_deployment( ) validate_deployment_plan_identity(plan) - validate_applied_release_identity(release) + if isinstance(release, ReleaseSnapshot): + validate_release_snapshot_identity(release) + release_id = release.release_snapshot_id + else: + validate_applied_release_identity(release) + release_id = release.applied_release_id validate_contractops_authorization_identity(authorization) if authorization.operation is not GovernanceOperation.DEPLOY: @@ -60,61 +67,66 @@ def authorize_deployment( deployment_authorization_id = compute_deployment_authorization_id( contract_ops_authorization_id=authorization.authorization_id, deployment_plan_id=plan.deployment_plan_id, - applied_release_id=release.applied_release_id, + applied_release_id=release_id, allowed=authorization.allowed, ) return DeploymentAuthorization( deployment_authorization_id=deployment_authorization_id, contract_ops_authorization_id=authorization.authorization_id, deployment_plan_id=plan.deployment_plan_id, - applied_release_id=release.applied_release_id, + applied_release_id=release_id, allowed=authorization.allowed, ) def _validate_plan_release_context( plan: DeploymentPlan, - release: AppliedContractRelease, + release: ReleaseSnapshot | AppliedContractRelease, ) -> None: - if plan.applied_release_id != release.applied_release_id: + release_id = ( + release.release_snapshot_id + if isinstance(release, ReleaseSnapshot) + else release.applied_release_id + ) + if plan.release_id != release_id: raise ReleaseValidationError( - "DeploymentPlan does not reference the supplied AppliedContractRelease" + "DeploymentPlan does not reference the supplied exact release" ) if plan.contract_id != release.contract_id: raise ReleaseValidationError( - "DeploymentPlan and AppliedContractRelease contract IDs do not match" + "DeploymentPlan and release contract IDs do not match" ) if plan.release_plan_id != release.release_plan_id: raise ReleaseValidationError( - "DeploymentPlan and AppliedContractRelease release plan IDs do not match" + "DeploymentPlan and release plan IDs do not match" ) if plan.released_revision_ref != release.release_revision_ref: raise ReleaseValidationError( - "DeploymentPlan released revision does not match AppliedContractRelease" + "DeploymentPlan released revision does not match release" ) if plan.selected_version != release.selected_version: raise ReleaseValidationError( - "DeploymentPlan selected version does not match AppliedContractRelease" + "DeploymentPlan selected version does not match release" ) def _validate_authorization_release_context( authorization: ContractOpsAuthorization, - release: AppliedContractRelease, + release: ReleaseSnapshot | AppliedContractRelease, ) -> None: if authorization.decision_id != release.decision_id: raise ReleaseValidationError( - "DEPLOY authorization decision does not match AppliedContractRelease" + "DEPLOY authorization decision does not match release" ) if authorization.change_set_id != release.change_set_id: raise ReleaseValidationError( - "DEPLOY authorization ChangeSet does not match AppliedContractRelease" + "DEPLOY authorization ChangeSet does not match release" ) if authorization.release_plan_id != release.release_plan_id: raise ReleaseValidationError( - "DEPLOY authorization ReleasePlan does not match AppliedContractRelease" + "DEPLOY authorization ReleasePlan does not match release" ) if authorization.version_resolution_id != release.version_resolution_id: raise ReleaseValidationError( - "DEPLOY authorization version resolution does not match AppliedContractRelease" + "DEPLOY authorization version resolution does not match release" ) From 34dbcc1cf8d616dcf3a8a1182d6b04906f5ea1d9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:47:43 +1000 Subject: [PATCH 053/616] feat(application): model bundle-driven deployment result --- .../application/models/deployment_workflow.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py index e0558540..2f3a08f5 100644 --- a/semapact/application/models/deployment_workflow.py +++ b/semapact/application/models/deployment_workflow.py @@ -15,9 +15,24 @@ ) from semapact.deployment import DeploymentPlan, DeploymentPreview from semapact.governance import GovernanceDecision +from semapact.reconciliation import ReconciliationResult, RuntimeDriftStatus from semapact.utils.deterministic import canonical_compact_json +class DeploymentExecutionResult(BaseModel): + """Application result for one bundle-driven CD execution.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + bundle_digest: str + deployment_plan_id: str + authorization_id: str + fresh_preview: DeploymentPreview + reconciliation: ReconciliationResult + status: RuntimeDriftStatus + review_preview_changed: bool + + class DeploymentBundle(BaseModel): """Immutable CI-to-CD package around existing canonical domain artifacts. From b2bfff2cc861cb060ee0dfd94a8564dba2a6b0f5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:47:50 +1000 Subject: [PATCH 054/616] feat(application): export deployment execution result --- semapact/application/models/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/semapact/application/models/__init__.py b/semapact/application/models/__init__.py index d343ffc0..7a590aaa 100644 --- a/semapact/application/models/__init__.py +++ b/semapact/application/models/__init__.py @@ -2,6 +2,7 @@ from semapact.application.models.deployment_workflow import ( DeploymentBundle, + DeploymentExecutionResult, build_deployment_bundle, compute_deployment_bundle_digest, ) @@ -23,6 +24,7 @@ "ContractEvolution", "DeploymentEvolution", "DeploymentBundle", + "DeploymentExecutionResult", "build_deployment_bundle", "compute_deployment_bundle_digest", "GovernanceAnalysis", From d4c6db19731b4a6e11add2dea7f98bb415220260 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:48:05 +1000 Subject: [PATCH 055/616] feat(application): execute exact deployment bundles in CD --- .../services/deployment_workflow.py | 89 ++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index bfbb20bb..cb979426 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -8,12 +8,24 @@ from semapact.application.models.deployment_workflow import ( DeploymentBundle, + DeploymentExecutionResult, build_deployment_bundle, ) from semapact.application.services.deployment import DeploymentService from semapact.application.services.release_planning import ReleasePlanningService -from semapact.contractops import build_release_snapshot -from semapact.deployment import DeploymentAdapter, DeploymentTarget +from semapact.approval import ApprovalRecord, project_review_authorization_evidence +from semapact.contractops import ( + authorize_contract_operation, + build_release_snapshot, +) +from semapact.deployment import ( + DeploymentAdapter, + DeploymentTarget, + authorize_deployment, +) +from semapact.exceptions import ContractOpsAuthorizationError, ValidationError +from semapact.governance import DecisionResult, GovernanceOperation +from semapact.reconciliation import classify_reconciliation_status class DeploymentWorkflowService: @@ -68,3 +80,76 @@ def assess( deployment_plan=plan, review_preview=preview, ) + + def deploy( + self, + bundle: DeploymentBundle, + *, + adapter: DeploymentAdapter, + approval: ApprovalRecord | None = None, + ) -> DeploymentExecutionResult: + """Consume one exact bundle, authorize, fresh-preview, execute, and verify.""" + if not isinstance(bundle, DeploymentBundle): + raise TypeError( + f"bundle must be DeploymentBundle, got {type(bundle).__name__}" + ) + + evidence = None + if bundle.decision.decision is DecisionResult.REVIEW: + if approval is None: + raise ContractOpsAuthorizationError( + "Deployment requires approval for a REVIEW governance decision" + ) + if bundle.bundle_digest not in approval.evidence_references: + raise ValidationError( + "ApprovalRecord does not reference the exact DeploymentBundle digest" + ) + evidence = project_review_authorization_evidence(approval) + + contract_authorization = authorize_contract_operation( + bundle.decision, + bundle.change_set, + bundle.release_plan, + bundle.version_resolution, + GovernanceOperation.DEPLOY, + evidence=evidence, + ) + if not contract_authorization.allowed: + raise ContractOpsAuthorizationError( + "Deployment is not authorized: " + f"{contract_authorization.reason.value}" + ) + + deployment_authorization = authorize_deployment( + bundle.deployment_plan, + bundle.release_snapshot, + contract_authorization, + ) + + # CI-time review preview is evidence only. CD always derives a fresh preview. + fresh_preview = self._deployment.preview( + bundle.deployment_plan, + adapter=adapter, + ) + self._deployment.execute( + bundle.deployment_plan, + fresh_preview, + deployment_authorization, + adapter=adapter, + ) + reconciliation = self._deployment.verify( + bundle.deployment_plan, + adapter=adapter, + ) + status = classify_reconciliation_status(reconciliation) + + return DeploymentExecutionResult( + bundle_digest=bundle.bundle_digest, + deployment_plan_id=bundle.deployment_plan.deployment_plan_id, + authorization_id=contract_authorization.authorization_id, + fresh_preview=fresh_preview, + reconciliation=reconciliation, + status=status, + review_preview_changed=fresh_preview != bundle.review_preview, + ) + From 83103ccf5d1a88ec087881ee9585dd39a5157ca9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:48:39 +1000 Subject: [PATCH 056/616] test(application): lock bundle-driven CD freshness and approval --- tests/test_deployment_workflow_service.py | 215 ++++++++++++++++++++-- 1 file changed, 200 insertions(+), 15 deletions(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index ee568257..f62c3673 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -11,6 +11,7 @@ SchemaProperty, ) +from semapact.approval import build_approval_record from semapact.application.models.deployment_workflow import DeploymentBundle from semapact.application.services.deployment_workflow import DeploymentWorkflowService from semapact.deployment import ( @@ -20,9 +21,12 @@ NativeOperation, NativeOperationKind, ) +from semapact.contractops import ReviewEvidenceAction from semapact.deployment.models import compute_deployment_preview_id +from semapact.exceptions import ContractOpsAuthorizationError, ValidationError +from semapact.governance import DecisionResult, GovernanceOperation from semapact.observation import ObservedPlatformState, with_observed_state_fingerprint -from semapact.reconciliation import ReconciliationResult +from semapact.reconciliation import ReconciliationResult, RuntimeDriftStatus class _PreviewAdapter(DeploymentAdapter): @@ -76,7 +80,90 @@ def verify(self, plan) -> ReconciliationResult: raise AssertionError("CI bundle construction must not verify deployment") -def _contract(*, name: str) -> OpenDataContractStandard: +class _ExecutionAdapter(DeploymentAdapter): + key = "fake" + + def __init__(self) -> None: + self.preview_calls = 0 + self.execute_calls = 0 + self.verify_calls = 0 + self.executed_preview = None + + def validate(self, plan) -> None: + pass + + def preview(self, plan) -> DeploymentPreview: + self.preview_calls += 1 + observation = with_observed_state_fingerprint( + ObservedPlatformState( + platform="fake", + source_identifier=plan.target.source_reference, + assets=(), + captured_at=datetime(2026, 9, 20, 1, tzinfo=timezone.utc), + ) + ) + assert observation.fingerprint is not None + operations = ( + NativeOperation( + kind=NativeOperationKind.NO_OP, + governed_asset="orders", + ), + ) + return DeploymentPreview( + deployment_preview_id=compute_deployment_preview_id( + deployment_plan_id=plan.deployment_plan_id, + platform="fake", + runtime_target=plan.target.runtime_target, + source_identifier=observation.source_identifier, + observation_fingerprint=observation.fingerprint, + operations=operations, + ), + deployment_plan_id=plan.deployment_plan_id, + platform="fake", + runtime_target=plan.target.runtime_target, + source_identifier=observation.source_identifier, + observation_fingerprint=observation.fingerprint, + operations=operations, + ) + + def execute(self, plan, preview, authorization) -> None: + assert authorization.allowed is True + self.execute_calls += 1 + self.executed_preview = preview + + def verify(self, plan) -> ReconciliationResult: + self.verify_calls += 1 + return ReconciliationResult( + contract_id=plan.contract_id, + contract_version=plan.selected_version, + observation_source_identifier=plan.target.source_reference, + observation_fingerprint="obs-v2:sha256:verified", + ) + + +def _contract( + *, + name: str, + include_created_at: bool = False, +) -> OpenDataContractStandard: + properties = [ + SchemaProperty( + name="id", + logicalType="string", + physicalType="varchar(255)", + required=True, + ) + ] + if include_created_at: + properties.append( + SchemaProperty( + name="created_at", + logicalType="timestamp", + physicalType="timestamp", + required=False, + ) + ) + return OpenDataContractStandard( apiVersion="v3.1.0", kind="DataContract", @@ -84,19 +171,7 @@ def _contract(*, name: str) -> OpenDataContractStandard: name=name, version="1.2.3", status="active", - schema=[ - SchemaObject( - name="orders", - properties=[ - SchemaProperty( - name="id", - logicalType="string", - physicalType="varchar(255)", - required=True, - ) - ], - ) - ], + schema=[SchemaObject(name="orders", properties=properties)], ) @@ -183,3 +258,113 @@ def test_bundle_rehydration_fails_closed_when_digest_is_tampered() -> None: with pytest.raises(PydanticValidationError, match="digest does not match"): DeploymentBundle.model_validate(payload) + + +def test_cd_uses_fresh_preview_not_ci_review_preview() -> None: + target = DeploymentTarget( + platform="fake", + runtime_target="main.sales", + source_reference="runtime:test", + ) + service = DeploymentWorkflowService() + bundle = service.assess( + _contract(name="Orders old"), + _contract(name="Orders new"), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=target, + adapter=_PreviewAdapter(), + ) + assert bundle.decision.decision is DecisionResult.ALLOW + assert bundle.review_preview.operations[0].kind is NativeOperationKind.CREATE + + adapter = _ExecutionAdapter() + result = service.deploy(bundle, adapter=adapter) + + assert adapter.preview_calls == 1 + assert adapter.execute_calls == 1 + assert adapter.verify_calls == 1 + assert adapter.executed_preview is result.fresh_preview + assert result.fresh_preview.operations[0].kind is NativeOperationKind.NO_OP + assert result.review_preview_changed is True + assert result.status is RuntimeDriftStatus.IN_SYNC + + +def test_review_deployment_requires_approval_bound_to_plan_and_bundle_digest() -> None: + target = DeploymentTarget( + platform="fake", + runtime_target="main.sales", + source_reference="runtime:test", + ) + service = DeploymentWorkflowService() + bundle = service.assess( + _contract(name="Orders"), + _contract(name="Orders", include_created_at=True), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=target, + adapter=_PreviewAdapter(), + ) + assert bundle.decision.decision is DecisionResult.REVIEW + + with pytest.raises(ContractOpsAuthorizationError, match="requires approval"): + service.deploy(bundle, adapter=_ExecutionAdapter()) + + approval = build_approval_record( + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=bundle.version_resolution.version_resolution_id, + operation=GovernanceOperation.DEPLOY, + action=ReviewEvidenceAction.APPROVE, + actor_reference="human:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + scope_reference=bundle.deployment_plan.deployment_plan_id, + evidence_references=(bundle.bundle_digest,), + ) + + result = service.deploy( + bundle, + adapter=_ExecutionAdapter(), + approval=approval, + ) + assert result.status is RuntimeDriftStatus.IN_SYNC + + +def test_review_deployment_rejects_approval_for_different_bundle_digest() -> None: + target = DeploymentTarget( + platform="fake", + runtime_target="main.sales", + source_reference="runtime:test", + ) + service = DeploymentWorkflowService() + bundle = service.assess( + _contract(name="Orders"), + _contract(name="Orders", include_created_at=True), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=target, + adapter=_PreviewAdapter(), + ) + approval = build_approval_record( + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=bundle.version_resolution.version_resolution_id, + operation=GovernanceOperation.DEPLOY, + action=ReviewEvidenceAction.APPROVE, + actor_reference="human:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + scope_reference=bundle.deployment_plan.deployment_plan_id, + evidence_references=("sha256:" + ("0" * 64),), + ) + + with pytest.raises(ValidationError, match="exact DeploymentBundle digest"): + service.deploy( + bundle, + adapter=_ExecutionAdapter(), + approval=approval, + ) From 6b6d62cf259c2e2de8f0bf507ae6e8c05551dc3e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:48:52 +1000 Subject: [PATCH 057/616] feat(cli): add bundle-driven CD deploy command --- .../interfaces/commands/deployment_cmd.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 29ede1f1..37e62b86 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -12,6 +12,7 @@ from pydantic import BaseModel from pydantic import ValidationError as PydanticValidationError +from semapact.approval import ApprovalRecord from semapact.application.models.deployment_workflow import DeploymentBundle from semapact.application.services.deployment import DeploymentService from semapact.contractops import AppliedContractRelease, ReleaseSnapshot @@ -103,6 +104,54 @@ def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: outcome=outcome_from_gate_result(gate), ) +def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: + """Consume one exact DeploymentBundle and run the canonical CD workflow.""" + from semapact.application.services.deployment_workflow import ( + DeploymentWorkflowService, + ) + from semapact.platforms.runtime_registry import create_deployment_adapter + + bundle = _load_model(args.bundle, DeploymentBundle) + approval = ( + _load_model(args.approval, ApprovalRecord) + if args.approval is not None + else None + ) + + execution_config = None + if bundle.deployment_plan.target.platform == "databricks": + from semapact.platforms.databricks.deployment import ( + DatabricksDeploymentExecutionConfig, + ) + + execution_config = DatabricksDeploymentExecutionConfig( + warehouse_id=args.warehouse_id, + ) + elif args.warehouse_id is not None: + raise ValidationError( + "--warehouse-id is only supported for Databricks deployment" + ) + + adapter = create_deployment_adapter( + bundle.deployment_plan.target.platform, + execution_config=execution_config, + ) + result = DeploymentWorkflowService().deploy( + bundle, + adapter=adapter, + approval=approval, + ) + rendered = ( + _model_json(result) + if args.output == "json" + else _deployment_result_text(result) + ) + return DeploymentCommandResult( + output=rendered, + outcome=outcome_from_reconciliation_status(result.status), + ) + + def run_deployment_plan(args: argparse.Namespace) -> DeploymentCommandResult: """Build one provider-neutral DeploymentPlan from an exact release artifact.""" release = _load_release_artifact(args.release) @@ -355,3 +404,27 @@ def _bundle_text( if artifact_path: lines.append(f"Bundle artifact: {artifact_path}") return "\n".join(lines) + + + +def _deployment_result_text(result) -> str: + lines = [ + f"Bundle digest: {result.bundle_digest}", + f"Deployment plan: {result.deployment_plan_id}", + f"Authorization: {result.authorization_id}", + "Provider execution: SUCCEEDED", + f"Verification: {result.status.value}", + ( + "CI review preview changed: " + f"{'yes' if result.review_preview_changed else 'no'}" + ), + "Fresh operations:", + ] + for operation in result.fresh_preview.operations: + detail = f" - {operation.kind.value} {operation.governed_asset}" + if operation.statement is not None: + detail += f": {operation.statement}" + lines.append(detail) + if not result.fresh_preview.operations: + lines.append(" - none") + return "\n".join(lines) From 98cd3f7a300e693ec3b161c1e7503393a0db6c5e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:49:01 +1000 Subject: [PATCH 058/616] feat(cli): expose bundle-driven deployment --- semapact/interfaces/cli.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index 41e682d8..de5e94b0 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -490,6 +490,26 @@ def _build_parser() -> argparse.ArgumentParser: ) _add_effective_date_argument(deployment_assess_parser) + deployment_deploy_parser = deployment_subparsers.add_parser( + "deploy", + help="Consume an immutable DeploymentBundle, execute against fresh runtime, and verify", + ) + deployment_deploy_parser.add_argument("--bundle", required=True) + deployment_deploy_parser.add_argument( + "--approval", + help="ApprovalRecord JSON required when governance decision is REVIEW", + ) + deployment_deploy_parser.add_argument( + "--warehouse-id", + help="Databricks SQL warehouse required when fresh operations mutate runtime", + ) + deployment_deploy_parser.add_argument( + "--output", + choices=["text", "json"], + default="text", + help="Output format (default: text)", + ) + deployment_plan_parser = deployment_subparsers.add_parser( "plan", help="Build a DeploymentPlan from an exact ReleaseSnapshot or legacy applied release" ) @@ -649,6 +669,7 @@ def main() -> int: if args.command == "deployment": from semapact.interfaces.commands.deployment_cmd import ( run_deployment_assess, + run_deployment_deploy, run_deployment_execute, run_deployment_plan, run_deployment_preview, @@ -658,6 +679,8 @@ def main() -> int: if args.deployment_command == "assess": result = run_deployment_assess(args) + elif args.deployment_command == "deploy": + result = run_deployment_deploy(args) elif args.deployment_command == "plan": result = run_deployment_plan(args) elif args.deployment_command == "preview": From 8b0a3495af03b1d3db40686a020f5086281bc1af Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:49:11 +1000 Subject: [PATCH 059/616] test(cli): expose bundle-driven deploy surface --- tests/interfaces/test_deployment_cmd.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index 14f17f08..f29c8b9d 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -99,6 +99,20 @@ def test_deployment_parser_exposes_read_only_assessment_and_explicit_phases() -> "json", ] ) + deploy = parser.parse_args( + [ + "deployment", + "deploy", + "--bundle", + "bundle.json", + "--approval", + "approval.json", + "--warehouse-id", + "warehouse-1", + "--output", + "json", + ] + ) plan = parser.parse_args( [ "deployment", @@ -136,6 +150,11 @@ def test_deployment_parser_exposes_read_only_assessment_and_explicit_phases() -> assert assess.output == "json" assert assess.server == "production" assert assess.bundle_out == "bundle.json" + assert deploy.deployment_command == "deploy" + assert deploy.bundle == "bundle.json" + assert deploy.approval == "approval.json" + assert deploy.warehouse_id == "warehouse-1" + assert deploy.output == "json" assert plan.deployment_command == "plan" assert plan.source_reference == SOURCE_REFERENCE assert preview.deployment_command == "preview" From 2792206241cbf240992cf9fc3d90c688afcb6c0d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:49:45 +1000 Subject: [PATCH 060/616] docs(deployment): document bundle-driven CD workflow --- docs/deployment_plans.md | 42 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 7d6708ef..745558b2 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -175,7 +175,7 @@ The previous candidate-specific `DeploymentAssessment` artifact is intentionally ## CLI workflow -The deployment CLI supports both contract-first assessment and explicit canonical artifacts. Assessment, planning, and preview are read-only; `execute` is the runtime mutation boundary. +The deployment CLI exposes a high-level bundle workflow plus low-level compatibility/debugging commands. `assess` is the canonical CI/manual planning surface; `deploy` is the canonical CD/manual execution surface. ### Assess @@ -195,6 +195,40 @@ When the candidate contract defines the selected server, its platform, catalog/s The bundle file contains the canonical planning artifacts, `ReleaseSnapshot`, v3 `DeploymentPlan`, and CI-time `reviewPreview`, protected by `bundleDigest`. Use `--output json` to emit that same bundle on stdout instead. CI can publish the file using its normal pipeline-artifact mechanism; SemaPact does not couple this package to one CI vendor. +### Deploy + +After CI publishes the exact bundle and any required human approval is recorded, CD consumes that bundle directly: + +```bash +semapact deployment deploy \ + --bundle ./artifacts/orders-prod.bundle.json \ + --approval ./artifacts/orders-prod.approval.json \ + --warehouse-id \ + --output json +``` + +`--approval` is required only when the bundle carries a `GovernanceDecision(REVIEW)`. The approval must be an exact `ApprovalRecord` for DEPLOY, scoped to the bundle's `deploymentPlanId`, and its evidence references must include the exact `bundleDigest`. + +The CD workflow deliberately ignores the CI-time review preview as execution input: + +```text +load + validate exact DeploymentBundle +→ validate approval / operation authorization +→ fresh runtime observation +→ fresh DeploymentPreview +→ execute exact fresh preview +→ fresh verification +→ IN_SYNC / DRIFT / INDETERMINATE +``` + +If runtime changed between CI and CD, the fresh preview may differ. A CI-time ALTER can become NO_OP; a newly unsafe or conflicting transition fails closed. Provider execution success is not sufficient: the command returns reconciliation semantics after a separate fresh verify. + +For governance-ALLOW deployments, `--approval` may be omitted. + +### Low-level artifact commands + +The following commands remain available for compatibility, diagnostics, and explicit artifact workflows. They are not the recommended CI/CD happy path. + ### Plan ```bash @@ -218,7 +252,7 @@ semapact deployment preview \ Preview observes the exact target scope and derives a canonical `DeploymentPreview`. It does not mutate runtime and does not require a Databricks SQL warehouse merely to inspect provider-native operations. -### Execute +### Execute (low-level compatibility) For a preview containing CREATE or ALTER operations, provide the SQL warehouse used for mutation: @@ -287,9 +321,9 @@ The adapter is also not a general Databricks infrastructure engine. Workspace, c Runtime deployment is a separate protected operation from publishing a contract release artifact. -A `ContractOpsAuthorization(operation=DEPLOY)` establishes release-context authorization. Before runtime mutation, it must be bound to the exact `DeploymentPlan` as a `DeploymentAuthorization`. +At the canonical application boundary, callers provide an exact `DeploymentBundle` and, for REVIEW decisions, an `ApprovalRecord`. The approval must bind the exact `deploymentPlanId` and include the exact `bundleDigest` as evidence. This makes the CI artifact itself part of the approval scope rather than approving a mutable path or a SQL string. -For review-required changes, structured review evidence may carry an opaque `scopeReference`. Deployment requires that scope to match the exact `deploymentPlanId`, so an approval for one exact platform/runtime/source target cannot be reused for another target. +The current low-level domain implementation still bridges this into the historical `ContractOpsAuthorization → DeploymentAuthorization` types before calling the adapter. That bridge is compatibility machinery; Data Engineers and CI/CD callers do not construct those artifacts in the bundle workflow. A future cleanup can collapse the bridge without changing the public bundle contract. A PUBLISH authorization cannot authorize DEPLOY. From a0e54d4194ed56c549331cebc04d51862161fe7e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:49:48 +1000 Subject: [PATCH 061/616] docs(readme): add bundle-driven CD deploy flow --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index e763e7e5..d9619bb4 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,10 @@ assess → content-addressed DeploymentBundle → read-only / no execution authority +deploy +→ exact DeploymentBundle + approval when REVIEW +→ fresh preview → execute → fresh verify + plan → DeploymentPlan @@ -349,6 +353,17 @@ semapact deployment assess \ The bundle contains the exact reviewed `ReleaseSnapshot`, v3 `DeploymentPlan`, CI-time review preview, and a SHA-256 content digest. CI publishes this file using its normal artifact mechanism; the preview remains review evidence only and must be refreshed at CD execution time. +CD then consumes that exact artifact: + +```bash +semapact deployment deploy \ + --bundle ./artifacts/orders-prod.bundle.json \ + --approval ./artifacts/orders-prod.approval.json \ + --warehouse-id +``` + +The approval is required only for REVIEW decisions and must bind the exact deployment plan and bundle digest. The deploy command re-observes runtime, derives a fresh preview, executes only that fresh plan, then performs a separate fresh convergence verification. + Low-level deployment commands remain available for explicit artifact workflows and compatibility: ```bash From 26742caccf7640ef7c0ff2b439ff9a93f9610135 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:49:57 +1000 Subject: [PATCH 062/616] docs(contractops): align deploy phase with bundle CD path --- docs/contractops_phases.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/contractops_phases.md b/docs/contractops_phases.md index a5b6afa2..6a41ae84 100644 --- a/docs/contractops_phases.md +++ b/docs/contractops_phases.md @@ -189,9 +189,11 @@ Git, storage, and other release-artifact publication behavior belongs in adapter ## DEPLOY -DEPLOY mutates a runtime toward a provider-neutral `DeploymentPlan` and is separate from release publication. +DEPLOY mutates a runtime toward the exact `DeploymentPlan` packaged by CI and is separate from release publication. -A release-context `ContractOpsAuthorization(operation=DEPLOY)` is not enough on its own. Runtime execution also requires a `DeploymentAuthorization` bound to the exact deployment plan, including its target. A review approval scoped to one deployment plan therefore cannot be rebound to another target. +The canonical application workflow consumes the immutable `DeploymentBundle` directly. For REVIEW decisions, the supplied `ApprovalRecord` must be scoped to the exact `deploymentPlanId` and reference the exact `bundleDigest`. The workflow then re-observes runtime, derives a fresh preview, executes, and performs a separate fresh convergence verification. + +The current domain layer still uses `ContractOpsAuthorization(operation=DEPLOY)` and `DeploymentAuthorization` internally as a compatibility bridge. Those types are not part of the new Data Engineer / CI-CD happy path and callers do not need to assemble them manually. Platform-specific execution belongs behind a deployment adapter. The adapter must not recompute governance, version authority, release planning, or approval semantics. From b0b3a38ccf4a2928b89c585c2dae96bf5c16d33d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:50:39 +1000 Subject: [PATCH 063/616] test(databricks): prove bundle CI-to-CD convergence --- tests/test_databricks_convergence_e2e.py | 71 ++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/test_databricks_convergence_e2e.py b/tests/test_databricks_convergence_e2e.py index 29f80bf6..b3fb0218 100644 --- a/tests/test_databricks_convergence_e2e.py +++ b/tests/test_databricks_convergence_e2e.py @@ -15,6 +15,7 @@ ) from semapact.application.services.deployment import DeploymentService +from semapact.application.services.deployment_workflow import DeploymentWorkflowService from semapact.change_context import ChangeContext from semapact.contractops import ( apply_contract_release, @@ -343,6 +344,76 @@ def _assert_in_sync(workflow) -> None: assert result.unverified_paths == () +def test_bundle_ci_to_cd_create_and_fresh_verify_converge() -> None: + workspace = _StatefulWorkspace(present=False) + adapter = _adapter(workspace) + base = _contract( + _property("id", "integer", required=True), + name="orders-old", + ) + candidate = _contract( + _property("id", "integer", required=True), + name="orders-new", + ) + service = DeploymentWorkflowService() + + bundle = service.assess( + base, + candidate, + effective_date=_CONTEXT.effective_date, + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=_target(), + adapter=adapter, + ) + assert bundle.review_preview.operations[0].kind is NativeOperationKind.CREATE + + result = service.deploy(bundle, adapter=adapter) + + assert result.status is RuntimeDriftStatus.IN_SYNC + assert result.fresh_preview.operations[0].kind is NativeOperationKind.CREATE + assert workspace.statements == [ + "CREATE TABLE `main`.`silver`.`orders` " + "(`id` INT NOT NULL) USING DELTA" + ] + + +def test_bundle_cd_replans_against_runtime_changed_after_ci() -> None: + workspace = _StatefulWorkspace(present=False) + adapter = _adapter(workspace) + base = _contract( + _property("id", "integer", required=True), + name="orders-old", + ) + candidate = _contract( + _property("id", "integer", required=True), + name="orders-new", + ) + service = DeploymentWorkflowService() + + bundle = service.assess( + base, + candidate, + effective_date=_CONTEXT.effective_date, + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=_target(), + adapter=adapter, + ) + assert bundle.review_preview.operations[0].kind is NativeOperationKind.CREATE + + # Another actor converges the table after CI but before CD. + workspace.present = True + workspace.columns = [("id", "INT", False)] + + result = service.deploy(bundle, adapter=adapter) + + assert result.status is RuntimeDriftStatus.IN_SYNC + assert result.review_preview_changed is True + assert result.fresh_preview.operations[0].kind is NativeOperationKind.NO_OP + assert workspace.statements == [] + + def test_missing_table_create_execute_and_fresh_verify_converge() -> None: workspace = _StatefulWorkspace(present=False) workflow = _workflow( From e616a57c42e1e3545154df462fedaf7df584ecc5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:51:15 +1000 Subject: [PATCH 064/616] feat(application): create exact bundle deployment approvals --- .../services/deployment_workflow.py | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index cb979426..f84a29e0 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date +from datetime import date, datetime from open_data_contract_standard.model import OpenDataContractStandard @@ -13,8 +13,13 @@ ) from semapact.application.services.deployment import DeploymentService from semapact.application.services.release_planning import ReleasePlanningService -from semapact.approval import ApprovalRecord, project_review_authorization_evidence +from semapact.approval import ( + ApprovalRecord, + build_approval_record, + project_review_authorization_evidence, +) from semapact.contractops import ( + ReviewEvidenceAction, authorize_contract_operation, build_release_snapshot, ) @@ -81,6 +86,33 @@ def assess( review_preview=preview, ) + def approve( + self, + bundle: DeploymentBundle, + *, + actor_reference: str, + recorded_at: datetime, + comment: str | None = None, + ) -> ApprovalRecord: + """Record an explicit human DEPLOY approval for one exact bundle.""" + if bundle.decision.decision is not DecisionResult.REVIEW: + raise ValidationError( + "Explicit deployment approval is only required for REVIEW decisions" + ) + return build_approval_record( + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=bundle.version_resolution.version_resolution_id, + operation=GovernanceOperation.DEPLOY, + action=ReviewEvidenceAction.APPROVE, + actor_reference=actor_reference, + recorded_at=recorded_at, + scope_reference=bundle.deployment_plan.deployment_plan_id, + comment=comment, + evidence_references=(bundle.bundle_digest,), + ) + def deploy( self, bundle: DeploymentBundle, From 1861945092baeb5447972924103e4de38d50bc09 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:51:25 +1000 Subject: [PATCH 065/616] refactor(interfaces): share ISO timestamp parsing --- semapact/interfaces/parsing.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 semapact/interfaces/parsing.py diff --git a/semapact/interfaces/parsing.py b/semapact/interfaces/parsing.py new file mode 100644 index 00000000..be4e36e7 --- /dev/null +++ b/semapact/interfaces/parsing.py @@ -0,0 +1,18 @@ +"""Shared interface parsing helpers with no domain ownership.""" + +from __future__ import annotations + +from datetime import datetime + + +def parse_iso_timestamp(value: str) -> datetime: + """Parse one timezone-aware ISO-8601 timestamp.""" + if not isinstance(value, str): + raise TypeError("timestamp must be an ISO-8601 string") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("timestamp must be an ISO-8601 timestamp") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError("timestamp must include an explicit timezone offset") + return parsed From ccccfed5bd163d33ab7aad06d44e95a47603c4f7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:51:27 +1000 Subject: [PATCH 066/616] refactor(approval): reuse shared timestamp parser --- semapact/interfaces/commands/approval_cmd.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/semapact/interfaces/commands/approval_cmd.py b/semapact/interfaces/commands/approval_cmd.py index 87b1534c..026f28a6 100644 --- a/semapact/interfaces/commands/approval_cmd.py +++ b/semapact/interfaces/commands/approval_cmd.py @@ -3,17 +3,16 @@ from __future__ import annotations from argparse import Namespace -from datetime import datetime - from semapact.application.services.approval_record import ApprovalRecordService from semapact.contractops import ReviewEvidenceAction from semapact.governance import GovernanceOperation +from semapact.interfaces.parsing import parse_iso_timestamp from semapact.platforms.git import GitWorkingTreeHistoryRepository def run_approval_record(args: Namespace) -> dict[str, object]: """Record one explicit review event through the application service boundary.""" - recorded_at = _parse_timestamp(args.recorded_at) + recorded_at = parse_iso_timestamp(args.recorded_at) service = ApprovalRecordService( GitWorkingTreeHistoryRepository(args.repository_root), ) @@ -34,13 +33,3 @@ def run_approval_record(args: Namespace) -> dict[str, object]: return record.model_dump(mode="json", by_alias=True) -def _parse_timestamp(value: str) -> datetime: - if not isinstance(value, str): - raise TypeError("recorded_at must be an ISO-8601 string") - try: - parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError as exc: - raise ValueError("recorded_at must be an ISO-8601 timestamp") from exc - if parsed.tzinfo is None or parsed.utcoffset() is None: - raise ValueError("recorded_at must include an explicit timezone offset") - return parsed From 77cbc34ef8fe655e8fac9b454befef19cf13e566 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:51:36 +1000 Subject: [PATCH 067/616] feat(cli): create approval records directly from bundles --- .../interfaces/commands/deployment_cmd.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 37e62b86..25a39230 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -23,6 +23,7 @@ DeploymentTarget, ) from semapact.exceptions import ValidationError +from semapact.interfaces.parsing import parse_iso_timestamp from semapact.interfaces.outcomes import ( ProcessOutcome, outcome_from_gate_result, @@ -104,6 +105,33 @@ def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: outcome=outcome_from_gate_result(gate), ) +def run_deployment_approve(args: argparse.Namespace) -> DeploymentCommandResult: + """Create one exact DEPLOY ApprovalRecord from a REVIEW DeploymentBundle.""" + from semapact.application.services.deployment_workflow import ( + DeploymentWorkflowService, + ) + + bundle = _load_model(args.bundle, DeploymentBundle) + approval = DeploymentWorkflowService().approve( + bundle, + actor_reference=args.actor_reference, + recorded_at=parse_iso_timestamp(args.recorded_at), + comment=args.comment, + ) + if args.approval_out: + _write_model_artifact(args.approval_out, approval) + + rendered = ( + _model_json(approval) + if args.output == "json" + else _approval_text(approval, artifact_path=args.approval_out) + ) + return DeploymentCommandResult( + output=rendered, + outcome=ProcessOutcome.SUCCESS, + ) + + def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: """Consume one exact DeploymentBundle and run the canonical CD workflow.""" from semapact.application.services.deployment_workflow import ( @@ -428,3 +456,25 @@ def _deployment_result_text(result) -> str: if not result.fresh_preview.operations: lines.append(" - none") return "\n".join(lines) + + + +def _approval_text( + approval: ApprovalRecord, + *, + artifact_path: str | None, +) -> str: + lines = [ + f"Approval: {approval.approval_id}", + f"Actor: {approval.actor_reference}", + f"Operation: {approval.operation.value}", + f"Action: {approval.action.value}", + f"Deployment plan: {approval.scope_reference}", + ] + if approval.evidence_references: + lines.append( + "Evidence: " + ", ".join(approval.evidence_references) + ) + if artifact_path: + lines.append(f"Approval artifact: {artifact_path}") + return "\n".join(lines) From 931c01a025a6d993b4bb32c3b55d3bdb121467a2 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:51:44 +1000 Subject: [PATCH 068/616] feat(cli): expose bundle-bound approval helper --- semapact/interfaces/cli.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index de5e94b0..c602ac4c 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -490,6 +490,29 @@ def _build_parser() -> argparse.ArgumentParser: ) _add_effective_date_argument(deployment_assess_parser) + deployment_approve_parser = deployment_subparsers.add_parser( + "approve", + help="Create an exact DEPLOY ApprovalRecord from a REVIEW DeploymentBundle", + ) + deployment_approve_parser.add_argument("--bundle", required=True) + deployment_approve_parser.add_argument("--actor-reference", required=True) + deployment_approve_parser.add_argument( + "--recorded-at", + required=True, + help="Approval timestamp as timezone-aware ISO-8601", + ) + deployment_approve_parser.add_argument("--comment") + deployment_approve_parser.add_argument( + "--approval-out", + help="Write the exact ApprovalRecord JSON artifact to this path", + ) + deployment_approve_parser.add_argument( + "--output", + choices=["text", "json"], + default="text", + help="Output format (default: text)", + ) + deployment_deploy_parser = deployment_subparsers.add_parser( "deploy", help="Consume an immutable DeploymentBundle, execute against fresh runtime, and verify", @@ -668,6 +691,7 @@ def main() -> int: if args.command == "deployment": from semapact.interfaces.commands.deployment_cmd import ( + run_deployment_approve, run_deployment_assess, run_deployment_deploy, run_deployment_execute, @@ -677,7 +701,9 @@ def main() -> int: ) from semapact.interfaces.outcomes import exit_code_from_outcome - if args.deployment_command == "assess": + if args.deployment_command == "approve": + result = run_deployment_approve(args) + elif args.deployment_command == "assess": result = run_deployment_assess(args) elif args.deployment_command == "deploy": result = run_deployment_deploy(args) From 0b9d73f9bbba5e624e962a5195e9d047acb53968 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 10:51:53 +1000 Subject: [PATCH 069/616] test(application): derive approvals from exact bundles --- tests/test_deployment_workflow_service.py | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index f62c3673..b8730e22 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -291,6 +291,37 @@ def test_cd_uses_fresh_preview_not_ci_review_preview() -> None: assert result.status is RuntimeDriftStatus.IN_SYNC +def test_workflow_approval_is_derived_from_exact_review_bundle() -> None: + target = DeploymentTarget( + platform="fake", + runtime_target="main.sales", + source_reference="runtime:test", + ) + service = DeploymentWorkflowService() + bundle = service.assess( + _contract(name="Orders"), + _contract(name="Orders", include_created_at=True), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=target, + adapter=_PreviewAdapter(), + ) + + approval = service.approve( + bundle, + actor_reference="human:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + comment="Approved for production", + ) + + assert approval.operation is GovernanceOperation.DEPLOY + assert approval.action is ReviewEvidenceAction.APPROVE + assert approval.scope_reference == bundle.deployment_plan.deployment_plan_id + assert approval.evidence_references == (bundle.bundle_digest,) + assert approval.decision_id == bundle.decision.decision_id + + def test_review_deployment_requires_approval_bound_to_plan_and_bundle_digest() -> None: target = DeploymentTarget( platform="fake", From 3bd2ecd7436e820ea381c42a25c22da557a8ded5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 12:45:45 +1000 Subject: [PATCH 070/616] docs(deployment): show standard CI artifact handoff --- docs/deployment_plans.md | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 745558b2..8e542182 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -195,6 +195,54 @@ When the candidate contract defines the selected server, its platform, catalog/s The bundle file contains the canonical planning artifacts, `ReleaseSnapshot`, v3 `DeploymentPlan`, and CI-time `reviewPreview`, protected by `bundleDigest`. Use `--output json` to emit that same bundle on stdout instead. CI can publish the file using its normal pipeline-artifact mechanism; SemaPact does not couple this package to one CI vendor. +### CI artifact handoff + +SemaPact deliberately emits an ordinary file artifact so CI systems can use their native immutable artifact store. The pipeline should publish the exact bundle file produced by `assess`; CD should download that artifact rather than rebuilding it from the merged repository state. + +Generic CI: + +```text +CI job + semapact deployment assess --bundle-out deployment.bundle.json + ↓ + publish deployment.bundle.json + ↓ + record immutable artifact/digest reference + ↓ +approval + ↓ +CD job + download the exact published deployment.bundle.json + ↓ + semapact deployment deploy --bundle deployment.bundle.json +``` + +GitHub Actions can use the normal artifact actions: + +```yaml +- name: Build SemaPact deployment bundle + run: | + semapact deployment assess \ + --base contracts/orders.base.yaml \ + --candidate contracts/orders.yaml \ + --base-revision-ref "git:${{ github.event.pull_request.base.sha }}" \ + --candidate-revision-ref "git:${{ github.event.pull_request.head.sha }}" \ + --effective-date "$(date -u +%F)" \ + --server production \ + --bundle-out artifacts/orders-prod.bundle.json + +- name: Publish deployment bundle + uses: actions/upload-artifact@v4 + with: + name: semapact-orders-prod + path: artifacts/orders-prod.bundle.json + if-no-files-found: error +``` + +The corresponding CD job downloads that exact artifact and passes it unchanged to `deployment deploy`. Azure DevOps should use the equivalent Pipeline Artifact publish/download tasks. SemaPact does not require a vendor-specific artifact registry. + +Do not regenerate the bundle in CD. Rebuilding would create a new CI boundary and could make approval refer to material different from what CD consumes. CD is allowed—and required—to re-observe runtime, but not to replace the approved desired-state bundle. + ### Deploy After CI publishes the exact bundle and any required human approval is recorded, CD consumes that bundle directly: From 5d81c2e63cd494de991e33ea75e76565bc87d16e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:32:54 +1000 Subject: [PATCH 071/616] refactor(cli): remove legacy deployment command handlers --- .../interfaces/commands/deployment_cmd.py | 180 +----------------- 1 file changed, 2 insertions(+), 178 deletions(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 25a39230..fb4e2bff 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -1,4 +1,4 @@ -"""CLI adapter for canonical deployment planning, execution, and verification.""" +"""CLI adapter for the bundle-driven deployment workflow.""" from __future__ import annotations @@ -14,14 +14,7 @@ from semapact.approval import ApprovalRecord from semapact.application.models.deployment_workflow import DeploymentBundle -from semapact.application.services.deployment import DeploymentService -from semapact.contractops import AppliedContractRelease, ReleaseSnapshot -from semapact.deployment import ( - DeploymentAuthorization, - DeploymentPlan, - DeploymentPreview, - DeploymentTarget, -) +from semapact.deployment import DeploymentTarget from semapact.exceptions import ValidationError from semapact.interfaces.parsing import parse_iso_timestamp from semapact.interfaces.outcomes import ( @@ -29,7 +22,6 @@ outcome_from_gate_result, outcome_from_reconciliation_status, ) -from semapact.reconciliation import classify_reconciliation_status _ModelT = TypeVar("_ModelT", bound=BaseModel) @@ -180,107 +172,6 @@ def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: ) -def run_deployment_plan(args: argparse.Namespace) -> DeploymentCommandResult: - """Build one provider-neutral DeploymentPlan from an exact release artifact.""" - release = _load_release_artifact(args.release) - target = DeploymentTarget( - platform=args.platform, - runtime_target=args.runtime, - source_reference=args.source_reference, - server_name=args.server, - ) - plan = DeploymentService().plan(release, target) - return DeploymentCommandResult( - output=_model_json(plan), - outcome=ProcessOutcome.SUCCESS, - ) - - -def run_deployment_preview(args: argparse.Namespace) -> DeploymentCommandResult: - """Observe exact runtime scope and render the adapter's canonical preview.""" - plan = _load_model(args.plan, DeploymentPlan) - from semapact.platforms.runtime_registry import create_deployment_adapter - - adapter = create_deployment_adapter(plan.target.platform) - preview = DeploymentService().preview( - plan, - adapter=adapter, - ) - return DeploymentCommandResult( - output=_model_json(preview), - outcome=ProcessOutcome.SUCCESS, - ) - - -def run_deployment_execute(args: argparse.Namespace) -> DeploymentCommandResult: - """Execute only an exact plan/preview/authorization tuple.""" - plan = _load_model(args.plan, DeploymentPlan) - preview = _load_model(args.preview, DeploymentPreview) - authorization = _load_model(args.authorization, DeploymentAuthorization) - - from semapact.platforms.runtime_registry import create_deployment_adapter - - execution_config = None - if plan.target.platform == "databricks": - from semapact.platforms.databricks.deployment import ( - DatabricksDeploymentExecutionConfig, - ) - - execution_config = DatabricksDeploymentExecutionConfig( - warehouse_id=args.warehouse_id, - ) - elif args.warehouse_id is not None: - raise ValidationError( - "--warehouse-id is only supported for Databricks deployment" - ) - - adapter = create_deployment_adapter( - plan.target.platform, - execution_config=execution_config, - ) - DeploymentService().execute( - plan, - preview, - authorization, - adapter=adapter, - ) - return DeploymentCommandResult( - output=json.dumps( - { - "convergenceVerified": False, - "deploymentPlanId": plan.deployment_plan_id, - "deploymentPreviewId": preview.deployment_preview_id, - "providerExecution": "SUCCEEDED", - }, - indent=2, - sort_keys=True, - ), - outcome=ProcessOutcome.SUCCESS, - ) - - -def run_deployment_verify(args: argparse.Namespace) -> DeploymentCommandResult: - """Verify exact DeploymentPlan convergence through the unified deployment adapter.""" - plan = _load_model(args.plan, DeploymentPlan) - from semapact.platforms.runtime_registry import create_deployment_adapter - - adapter = create_deployment_adapter(plan.target.platform) - result = DeploymentService().verify( - plan, - adapter=adapter, - ) - status = classify_reconciliation_status(result) - rendered = ( - _verification_json(status.value, result) - if args.output == "json" - else _verification_text(status.value, result) - ) - return DeploymentCommandResult( - output=rendered, - outcome=outcome_from_reconciliation_status(status), - ) - - def _load_model(path: str, model_type: type[_ModelT]) -> _ModelT: try: raw = ( @@ -295,27 +186,6 @@ def _load_model(path: str, model_type: type[_ModelT]) -> _ModelT: ) from exc -def _load_release_artifact(path: str) -> ReleaseSnapshot | AppliedContractRelease: - try: - raw = ( - sys.stdin.read() - if path == "-" - else Path(path).read_text(encoding="utf-8") - ) - except OSError as exc: - raise ValidationError(f"Invalid release artifact '{path}': {exc}") from exc - - errors: list[str] = [] - for model_type in (ReleaseSnapshot, AppliedContractRelease): - try: - return model_type.model_validate_json(raw) - except PydanticValidationError as exc: - errors.append(f"{model_type.__name__}: {exc}") - raise ValidationError( - f"Invalid release artifact '{path}': " + " | ".join(errors) - ) - - def _write_model_artifact(path: str, model: BaseModel) -> None: artifact_path = Path(path) artifact_path.parent.mkdir(parents=True, exist_ok=True) @@ -331,52 +201,6 @@ def _model_json(model: BaseModel) -> str: ) -def _verification_json(status: str, result: BaseModel) -> str: - return json.dumps( - { - "reconciliation": result.model_dump(mode="json"), - "status": status, - }, - indent=2, - ensure_ascii=False, - sort_keys=True, - ) - - -def _verification_text(status: str, result: BaseModel) -> str: - differences = getattr(result, "differences") - unverified_paths = getattr(result, "unverified_paths") - lines = [ - f"Status: {status}", - ( - f"Contract: {getattr(result, 'contract_id')}@" - f"{getattr(result, 'contract_version')}" - ), - f"Observation source: {getattr(result, 'observation_source_identifier')}", - f"Observation fingerprint: {getattr(result, 'observation_fingerprint')}", - ] - if differences: - lines.append("Differences:") - for difference in differences: - detail = f" - {difference.reason_code.value} {difference.path}" - if difference.expected is not None or difference.observed is not None: - detail += ( - f" expected={difference.expected!r}" - f" observed={difference.observed!r}" - ) - lines.append(detail) - else: - lines.append("Differences: none") - - if unverified_paths: - lines.append("Unverified paths:") - lines.extend(f" - {path}" for path in unverified_paths) - else: - lines.append("Unverified paths: none") - return "\n".join(lines) - - - def _assessment_source_reference(contract_server, cli_source_reference: str | None) -> str: cli_value = (cli_source_reference or "").strip() if contract_server is None: From 92ca37f569848f6b3689ee138b1ed5530a04d999 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:33:03 +1000 Subject: [PATCH 072/616] refactor(cli): expose only bundle deployment workflow --- semapact/interfaces/cli.py | 53 +------------------------------------- 1 file changed, 1 insertion(+), 52 deletions(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index c602ac4c..0f017baf 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -443,7 +443,7 @@ def _build_parser() -> argparse.ArgumentParser: deployment_parser = subparsers.add_parser( "deployment", - help="Plan, preview, execute, and verify governed runtime deployment", + help="Assess, approve, and deploy immutable governed runtime bundles", ) deployment_subparsers = deployment_parser.add_subparsers( dest="deployment_command", required=True @@ -533,45 +533,6 @@ def _build_parser() -> argparse.ArgumentParser: help="Output format (default: text)", ) - deployment_plan_parser = deployment_subparsers.add_parser( - "plan", help="Build a DeploymentPlan from an exact ReleaseSnapshot or legacy applied release" - ) - deployment_plan_parser.add_argument("--release", required=True) - deployment_plan_parser.add_argument("--platform", required=True) - deployment_plan_parser.add_argument("--runtime", required=True) - deployment_plan_parser.add_argument( - "--source-reference", - required=True, - help="Stable runtime source identity (for Databricks, the workspace host; never credentials)", - ) - deployment_plan_parser.add_argument("--server") - - deployment_preview_parser = deployment_subparsers.add_parser( - "preview", help="Observe runtime and derive provider-native deployment operations" - ) - deployment_preview_parser.add_argument("--plan", required=True) - - deployment_execute_parser = deployment_subparsers.add_parser( - "execute", help="Execute an exact authorized DeploymentPreview" - ) - deployment_execute_parser.add_argument("--plan", required=True) - deployment_execute_parser.add_argument("--preview", required=True) - deployment_execute_parser.add_argument("--authorization", required=True) - deployment_execute_parser.add_argument( - "--warehouse-id", - help="Databricks SQL warehouse required only when preview contains mutation operations", - ) - - deployment_verify_parser = deployment_subparsers.add_parser( - "verify", help="Verify DeploymentPlan convergence through M1 reconciliation" - ) - deployment_verify_parser.add_argument("--plan", required=True) - deployment_verify_parser.add_argument( - "--output", - choices=["text", "json"], - default="text", - help="Output format (default: text)", - ) return parser @@ -694,10 +655,6 @@ def main() -> int: run_deployment_approve, run_deployment_assess, run_deployment_deploy, - run_deployment_execute, - run_deployment_plan, - run_deployment_preview, - run_deployment_verify, ) from semapact.interfaces.outcomes import exit_code_from_outcome @@ -707,14 +664,6 @@ def main() -> int: result = run_deployment_assess(args) elif args.deployment_command == "deploy": result = run_deployment_deploy(args) - elif args.deployment_command == "plan": - result = run_deployment_plan(args) - elif args.deployment_command == "preview": - result = run_deployment_preview(args) - elif args.deployment_command == "execute": - result = run_deployment_execute(args) - elif args.deployment_command == "verify": - result = run_deployment_verify(args) else: parser.error(f"Unknown deployment command: {args.deployment_command}") print(result.output) From c79cb3fa475c9d1ef0c44f3677b81979e944c6b8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:33:17 +1000 Subject: [PATCH 073/616] test(cli): drop legacy deployment command coverage --- tests/interfaces/test_deployment_cmd.py | 254 ++++-------------------- 1 file changed, 39 insertions(+), 215 deletions(-) diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index f29c8b9d..b6b33b97 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -1,80 +1,21 @@ from __future__ import annotations -import json import sys -from open_data_contract_standard.model import ( - OpenDataContractStandard, - SchemaObject, - SchemaProperty, - Server, -) +from open_data_contract_standard.model import Server import pytest -from semapact.contractops import AppliedContractRelease -from semapact.deployment import ( - DeploymentAdapter, - DeploymentTarget, - build_deployment_plan, -) -from semapact.contractops.integrity import compute_applied_release_id from semapact.exceptions import ValidationError from semapact.interfaces import cli from semapact.interfaces.commands import deployment_cmd from semapact.interfaces.commands.deployment_cmd import DeploymentCommandResult from semapact.interfaces.outcomes import ProcessOutcome -from semapact.reconciliation import ReconciliationResult -SOURCE_REFERENCE = "https://workspace.example" - -def _release() -> AppliedContractRelease: - contract = OpenDataContractStandard( - apiVersion="v3.1.0", - kind="DataContract", - id="orders-product", - name="Orders", - version="1.2.0", - status="active", - schema=[ - SchemaObject( - name="orders", - physicalName="orders_runtime", - properties=[ - SchemaProperty( - name="id", - physicalName="order_id", - type="integer", - physicalType="BIGINT", - required=True, - ) - ], - ) - ], - ) - released_contract_json = json.dumps( - contract.model_dump(mode="json", by_alias=True, exclude_none=True), - sort_keys=True, - separators=(",", ":"), - ) - fields = { - "contract_id": "orders-product", - "decision_id": "decision:test", - "change_set_id": "change-set:test", - "release_plan_id": "release-plan:test", - "version_resolution_id": "version-resolution:test", - "release_revision_ref": "rev:released", - "selected_version": "1.2.0", - "authorization_id": "authorization:test", - "released_contract_json": released_contract_json, - } - return AppliedContractRelease( - applied_release_id=compute_applied_release_id(**fields), - **fields, - ) +SOURCE_REFERENCE = "https://workspace.example" -def test_deployment_parser_exposes_read_only_assessment_and_explicit_phases() -> None: +def test_deployment_parser_exposes_only_bundle_workflow() -> None: parser = cli._build_parser() assess = parser.parse_args( @@ -99,6 +40,22 @@ def test_deployment_parser_exposes_read_only_assessment_and_explicit_phases() -> "json", ] ) + approve = parser.parse_args( + [ + "deployment", + "approve", + "--bundle", + "bundle.json", + "--actor-reference", + "human:reviewer", + "--recorded-at", + "2026-09-20T10:00:00+10:00", + "--approval-out", + "approval.json", + "--output", + "json", + ] + ) deploy = parser.parse_args( [ "deployment", @@ -113,100 +70,34 @@ def test_deployment_parser_exposes_read_only_assessment_and_explicit_phases() -> "json", ] ) - plan = parser.parse_args( - [ - "deployment", - "plan", - "--release", - "release.json", - "--platform", - "databricks", - "--runtime", - "main.silver", - "--source-reference", - SOURCE_REFERENCE, - ] - ) - preview = parser.parse_args( - ["deployment", "preview", "--plan", "plan.json"] - ) - execute = parser.parse_args( - [ - "deployment", - "execute", - "--plan", - "plan.json", - "--preview", - "preview.json", - "--authorization", - "authorization.json", - ] - ) - verify = parser.parse_args( - ["deployment", "verify", "--plan", "plan.json", "--output", "json"] - ) assert assess.deployment_command == "assess" - assert assess.output == "json" assert assess.server == "production" assert assess.bundle_out == "bundle.json" + assert assess.output == "json" + + assert approve.deployment_command == "approve" + assert approve.bundle == "bundle.json" + assert approve.actor_reference == "human:reviewer" + assert approve.approval_out == "approval.json" + assert deploy.deployment_command == "deploy" assert deploy.bundle == "bundle.json" assert deploy.approval == "approval.json" assert deploy.warehouse_id == "warehouse-1" assert deploy.output == "json" - assert plan.deployment_command == "plan" - assert plan.source_reference == SOURCE_REFERENCE - assert preview.deployment_command == "preview" - assert not hasattr(preview, "warehouse_id") - assert execute.deployment_command == "execute" - assert execute.warehouse_id is None - assert verify.deployment_command == "verify" - assert verify.output == "json" -def test_plan_command_outputs_canonical_deployment_plan(tmp_path) -> None: - release_path = tmp_path / "release.json" - release = _release() - release_path.write_text(release.model_dump_json(), encoding="utf-8") - args = cli._build_parser().parse_args( - [ - "deployment", - "plan", - "--release", - str(release_path), - "--platform", - "databricks", - "--runtime", - "main.silver", - "--source-reference", - SOURCE_REFERENCE, - "--server", - "production", - ] - ) - - result = deployment_cmd.run_deployment_plan(args) - payload = json.loads(result.output) - - assert result.outcome is ProcessOutcome.SUCCESS - assert payload["applied_release_id"] == release.applied_release_id - assert payload["target"] == { - "platform": "databricks", - "runtime_target": "main.silver", - "source_reference": SOURCE_REFERENCE, - "server_name": "production", - } - assert payload["actions"][0]["governed_asset"] == "orders" - assert payload["actions"][0]["physical_name"] == "orders_runtime" - +@pytest.mark.parametrize("legacy_command", ["plan", "preview", "execute", "verify"]) +def test_legacy_deployment_commands_are_not_public_cli( + legacy_command: str, +) -> None: + parser = cli._build_parser() -def test_invalid_artifact_is_a_validation_failure(tmp_path) -> None: - invalid = tmp_path / "invalid.json" - invalid.write_text("{}", encoding="utf-8") + with pytest.raises(SystemExit) as exc: + parser.parse_args(["deployment", legacy_command]) - with pytest.raises(ValidationError, match="Invalid DeploymentPlan artifact"): - deployment_cmd._load_model(str(invalid), deployment_cmd.DeploymentPlan) + assert exc.value.code == 2 @pytest.mark.parametrize( @@ -217,7 +108,7 @@ def test_invalid_artifact_is_a_validation_failure(tmp_path) -> None: (ProcessOutcome.RUNTIME_INDETERMINATE, 7), ], ) -def test_main_preserves_verification_outcome_semantics( +def test_main_preserves_bundle_deploy_outcome_semantics( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], outcome: ProcessOutcome, @@ -225,84 +116,17 @@ def test_main_preserves_verification_outcome_semantics( ) -> None: monkeypatch.setattr( deployment_cmd, - "run_deployment_verify", - lambda args: DeploymentCommandResult(output="verification", outcome=outcome), + "run_deployment_deploy", + lambda args: DeploymentCommandResult(output="deployment", outcome=outcome), ) monkeypatch.setattr( sys, "argv", - ["semapact", "deployment", "verify", "--plan", "plan.json"], + ["semapact", "deployment", "deploy", "--bundle", "bundle.json"], ) assert cli.main() == expected_exit - assert capsys.readouterr().out.strip() == "verification" - - - -def test_verify_command_uses_unified_deployment_adapter( - tmp_path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - plan = build_deployment_plan( - _release(), - DeploymentTarget( - platform="databricks", - runtime_target="main.silver", - source_reference=SOURCE_REFERENCE, - ), - ) - plan_path = tmp_path / "plan.json" - plan_path.write_text(plan.model_dump_json(), encoding="utf-8") - - class _Adapter(DeploymentAdapter): - key = "databricks" - - def __init__(self) -> None: - self.verify_calls = 0 - - def validate(self, plan): - raise AssertionError("CLI should call the service entrypoint") - - def preview(self, plan): - raise AssertionError("preview should not be called") - - def execute(self, plan, preview, authorization): - raise AssertionError("execute should not be called") - - def verify(self, plan): - self.verify_calls += 1 - return ReconciliationResult( - contract_id=plan.contract_id, - contract_version=plan.selected_version, - observation_source_identifier=SOURCE_REFERENCE, - observation_fingerprint="obs-v2:sha256:test", - ) - - adapter = _Adapter() - - import semapact.platforms.runtime_registry as runtime_registry - - monkeypatch.setattr( - runtime_registry, - "create_deployment_adapter", - lambda platform: adapter, - ) - - args = cli._build_parser().parse_args( - [ - "deployment", - "verify", - "--plan", - str(plan_path), - "--output", - "json", - ] - ) - result = deployment_cmd.run_deployment_verify(args) - - assert result.outcome is ProcessOutcome.SUCCESS - assert adapter.verify_calls == 1 - assert json.loads(result.output)["status"] == "IN_SYNC" + assert capsys.readouterr().out.strip() == "deployment" def test_assessment_source_reference_prefers_contract_server_host() -> None: From cb9a91903ebe8fe3fd696b8dbed61af8d578ef50 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:33:24 +1000 Subject: [PATCH 074/616] docs(deployment): remove legacy CLI workflow --- docs/deployment_plans.md | 67 +--------------------------------------- 1 file changed, 1 insertion(+), 66 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 8e542182..d7187414 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -175,7 +175,7 @@ The previous candidate-specific `DeploymentAssessment` artifact is intentionally ## CLI workflow -The deployment CLI exposes a high-level bundle workflow plus low-level compatibility/debugging commands. `assess` is the canonical CI/manual planning surface; `deploy` is the canonical CD/manual execution surface. +The deployment CLI exposes one bundle workflow: `assess` for CI/manual planning, `approve` for explicit REVIEW approval, and `deploy` for CD/manual execution. ### Assess @@ -273,71 +273,6 @@ If runtime changed between CI and CD, the fresh preview may differ. A CI-time AL For governance-ALLOW deployments, `--approval` may be omitted. -### Low-level artifact commands - -The following commands remain available for compatibility, diagnostics, and explicit artifact workflows. They are not the recommended CI/CD happy path. - -### Plan - -```bash -semapact deployment plan \ - --release ./artifacts/release-snapshot.json \ - --platform databricks \ - --runtime main.sales \ - --source-reference https://dbc-example.cloud.databricks.com -``` - -`--source-reference` must match the stable source identity reported by the runtime provider. Optional `--server` preserves the selected contract-server reference as target provenance. - -The output is the canonical `DeploymentPlan` JSON. - -### Preview - -```bash -semapact deployment preview \ - --plan ./artifacts/deployment-plan.json -``` - -Preview observes the exact target scope and derives a canonical `DeploymentPreview`. It does not mutate runtime and does not require a Databricks SQL warehouse merely to inspect provider-native operations. - -### Execute (low-level compatibility) - -For a preview containing CREATE or ALTER operations, provide the SQL warehouse used for mutation: - -```bash -semapact deployment execute \ - --plan ./artifacts/deployment-plan.json \ - --preview ./artifacts/deployment-preview.json \ - --authorization ./artifacts/deployment-authorization.json \ - --warehouse-id -``` - -For an all-`NO_OP` preview, `--warehouse-id` may be omitted because no native mutation is executed. Any CREATE/ALTER attempt without a warehouse fails closed. - -Execution requires the exact plan, exact preview, and exact `DeploymentAuthorization`. The adapter re-observes the target, validates the authorized runtime source and observation fingerprint, re-derives the expected preview for integrity/freshness validation, and executes only the supplied operations when the artifacts still match. - -Provider execution success is not convergence proof. - -Complete DDL export remains a useful inspection or integration utility, especially for creating new assets, but export is not a lifecycle phase. Exported SQL is derived output; deployment planning remains responsible for comparing the exact released contract with fresh runtime evidence before any mutation is authorized. - -### Verify - -```bash -semapact deployment verify \ - --plan ./artifacts/deployment-plan.json \ - --output json -``` - -Verification enters through the same DeploymentAdapter / DeploymentOrchestrator boundary, performs fresh runtime observation, and reuses the normal reconciliation semantics with the same platform schema mapper used by preview: - -| Runtime status | Exit code | -| --- | ---: | -| `IN_SYNC` | `0` | -| `DRIFT` | `6` | -| `INDETERMINATE` | `7` | - -This keeps execution status separate from convergence evidence. VERIFY is assurance/read-side behavior: provider mutation restrictions such as Databricks MANAGED-only writes do not prevent SemaPact from verifying an observable non-managed asset. - ## Databricks deployment capability The first Databricks write slice is intentionally narrow and fail-closed. From 640a52c399d22e91eb1db5714737af4998cc17ab Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:33:40 +1000 Subject: [PATCH 075/616] style(cli): keep bundle command module clean --- semapact/interfaces/commands/deployment_cmd.py | 1 + 1 file changed, 1 insertion(+) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index fb4e2bff..df26cdc9 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -97,6 +97,7 @@ def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: outcome=outcome_from_gate_result(gate), ) + def run_deployment_approve(args: argparse.Namespace) -> DeploymentCommandResult: """Create one exact DEPLOY ApprovalRecord from a REVIEW DeploymentBundle.""" from semapact.application.services.deployment_workflow import ( From 80af56c8f13f41b8b9af466827113f5905df4fb0 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:33:49 +1000 Subject: [PATCH 076/616] docs(readme): remove legacy deployment CLI surface --- README.md | 34 +++++++--------------------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d9619bb4..621eb9a9 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Lifecycle status does not itself mean that a revision has been authorized for re ### Side effects are operation-scoped -SemaPact distinguishes analysis and planning from side effects. APPLY, PUBLISH, and DEPLOY are separate protected operations. A publication authorization cannot be reused as runtime deployment authority. +SemaPact distinguishes pure planning/materialization from external side effects. PUBLISH and DEPLOY are protected operation scopes; release snapshot construction itself is pure. A publication authorization cannot be reused as runtime deployment authority. ### Runtime-aware without becoming platform-owned @@ -155,7 +155,7 @@ SemaPact currently supports deterministic change analysis and lifecycle-aware po - relationship change handling; - version-policy classification; - deterministic `GovernanceDecision` artifacts; -- centralized governance gates for ANALYZE / PROPOSE / APPLY / PUBLISH / DEPLOY / CI operations. +- centralized governance and authorization boundaries for planning, publication, and deployment operations. ### Canonical ContractOps release planning @@ -179,7 +179,7 @@ See [`docs/contractops_phases.md`](docs/contractops_phases.md) and [`docs/versio ### Governed runtime deployment -`semapact deployment` exposes a canonical CLI/CI surface: +`semapact deployment` exposes one bundle-driven CLI/CI workflow: ```text assess @@ -187,27 +187,16 @@ assess → content-addressed DeploymentBundle → read-only / no execution authority +approve +→ explicit ApprovalRecord for REVIEW decisions + deploy → exact DeploymentBundle + approval when REVIEW → fresh preview → execute → fresh verify - -plan -→ DeploymentPlan - -preview -→ fresh runtime observation -→ DeploymentPreview - -execute -→ exact plan + preview + DeploymentAuthorization -→ provider execution - -verify -→ fresh observation + reconciliation → IN_SYNC / DRIFT / INDETERMINATE ``` -Assessment, planning, and preview are read-only. The CI assessment path writes a content-addressed `DeploymentBundle` that can be published as a pipeline artifact for later CD consumption. Runtime mutation occurs only through the execution boundary, and provider execution success is not treated as convergence proof. +`assess` is the CI/manual planning boundary, `approve` records explicit review when required, and `deploy` is the CD/manual execution boundary. Internal planning, preview, execution, and verification primitives remain application/domain APIs rather than separate Data Engineer CLI phases. The first Databricks write capability is intentionally narrow: create a missing managed Delta table, add missing nullable governed columns to an existing managed table, or perform NO_OP when the governed shape is already satisfied. Rename, existing-column type/nullability mutation, required-column addition without a safe migration strategy, DROP, and existing external/non-managed asset mutation fail closed. @@ -364,15 +353,6 @@ semapact deployment deploy \ The approval is required only for REVIEW decisions and must bind the exact deployment plan and bundle digest. The deploy command re-observes runtime, derives a fresh preview, executes only that fresh plan, then performs a separate fresh convergence verification. -Low-level deployment commands remain available for explicit artifact workflows and compatibility: - -```bash -semapact deployment plan --help -semapact deployment preview --help -semapact deployment execute --help -semapact deployment verify --help -``` - ## Optional Dependencies | Extra | Purpose | From 28e24cd57e9a287a46ce1194083f520c84df738a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:33:59 +1000 Subject: [PATCH 077/616] docs(contractops): keep legacy apply out of canonical flow --- docs/contractops_phases.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/contractops_phases.md b/docs/contractops_phases.md index 6a41ae84..5f1606ce 100644 --- a/docs/contractops_phases.md +++ b/docs/contractops_phases.md @@ -24,9 +24,6 @@ AUTHORIZE PUBLISH / DEPLOY → external side effects - -Legacy APPLY compatibility -→ AppliedContractRelease ``` Each phase consumes artifacts from the previous phases. Later phases do not recalculate earlier decisions. From 12cc340404fc4871c7cbd19cfa0e7c09c679e28b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:48:02 +1000 Subject: [PATCH 078/616] feat(approval): resolve exact deployment approval from history --- .../services/deployment_approval.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 semapact/application/services/deployment_approval.py diff --git a/semapact/application/services/deployment_approval.py b/semapact/application/services/deployment_approval.py new file mode 100644 index 00000000..a8b6ba34 --- /dev/null +++ b/semapact/application/services/deployment_approval.py @@ -0,0 +1,42 @@ +"""Resolve exact deployment approval evidence from immutable approval history.""" + +from __future__ import annotations + +from semapact.approval import ApprovalRecord +from semapact.application.models.deployment_workflow import DeploymentBundle +from semapact.contractops import ReviewEvidenceAction +from semapact.governance import GovernanceOperation +from semapact.history import ApprovalHistoryRepository + + +class DeploymentApprovalResolver: + """Select exact APPROVE evidence for one immutable DeploymentBundle. + + History persistence remains storage-only. This resolver owns the deployment-specific + selection rule: an approval must match the exact ContractOps context, DEPLOY scope, + deployment plan, and bundle digest. Multiple exact approvals are equivalent for + authorization; selection is deterministic by canonical approval identity. + """ + + def __init__(self, approvals: ApprovalHistoryRepository) -> None: + self._approvals = approvals + + def resolve(self, bundle: DeploymentBundle) -> ApprovalRecord | None: + records = self._approvals.list_approval_records_for_context( + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=bundle.version_resolution.version_resolution_id, + operation=GovernanceOperation.DEPLOY, + ) + matches = tuple( + record + for record in records + if record.action is ReviewEvidenceAction.APPROVE + and record.scope_reference + == bundle.deployment_plan.deployment_plan_id + and bundle.bundle_digest in record.evidence_references + ) + if not matches: + return None + return min(matches, key=lambda record: record.approval_id) From c8bdbb2b7bcca8a3f3382de7b976752fbcfd2006 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:48:18 +1000 Subject: [PATCH 079/616] feat(cli): resolve review approval from Git history by default --- .../interfaces/commands/deployment_cmd.py | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index df26cdc9..d46be392 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -133,10 +133,10 @@ def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: from semapact.platforms.runtime_registry import create_deployment_adapter bundle = _load_model(args.bundle, DeploymentBundle) - approval = ( - _load_model(args.approval, ApprovalRecord) - if args.approval is not None - else None + approval = _resolve_deployment_approval( + bundle, + approval_path=args.approval, + repository_root=args.repository_root, ) execution_config = None @@ -173,6 +173,29 @@ def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: ) +def _resolve_deployment_approval( + bundle: DeploymentBundle, + *, + approval_path: str | None, + repository_root: str, +) -> ApprovalRecord | None: + if approval_path is not None: + return _load_model(approval_path, ApprovalRecord) + + from semapact.application.services.deployment_approval import ( + DeploymentApprovalResolver, + ) + from semapact.governance import DecisionResult + from semapact.platforms.git import GitWorkingTreeHistoryRepository + + if bundle.decision.decision is not DecisionResult.REVIEW: + return None + + return DeploymentApprovalResolver( + GitWorkingTreeHistoryRepository(repository_root), + ).resolve(bundle) + + def _load_model(path: str, model_type: type[_ModelT]) -> _ModelT: try: raw = ( From 53be4282a914e0042fb7db1813e0871748cd796c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:48:27 +1000 Subject: [PATCH 080/616] feat(cli): make explicit deployment approval optional --- semapact/interfaces/cli.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index 0f017baf..6c151612 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -520,7 +520,18 @@ def _build_parser() -> argparse.ArgumentParser: deployment_deploy_parser.add_argument("--bundle", required=True) deployment_deploy_parser.add_argument( "--approval", - help="ApprovalRecord JSON required when governance decision is REVIEW", + help=( + "Optional ApprovalRecord JSON. When omitted for REVIEW, SemaPact " + "resolves an exact matching approval from Git-backed history." + ), + ) + deployment_deploy_parser.add_argument( + "--repository-root", + default=".", + help=( + "Repository root containing .semapact/history approval records " + "(default: current directory)" + ), ) deployment_deploy_parser.add_argument( "--warehouse-id", From 672d85706790bd53d1e7fa244c89eba9e7df7b31 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:48:42 +1000 Subject: [PATCH 081/616] test(approval): resolve exact deployment approvals from Git history --- tests/test_deployment_workflow_service.py | 65 +++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index b8730e22..50ce0f14 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -13,6 +13,7 @@ from semapact.approval import build_approval_record from semapact.application.models.deployment_workflow import DeploymentBundle +from semapact.application.services.deployment_approval import DeploymentApprovalResolver from semapact.application.services.deployment_workflow import DeploymentWorkflowService from semapact.deployment import ( DeploymentAdapter, @@ -26,6 +27,7 @@ from semapact.exceptions import ContractOpsAuthorizationError, ValidationError from semapact.governance import DecisionResult, GovernanceOperation from semapact.observation import ObservedPlatformState, with_observed_state_fingerprint +from semapact.platforms.git import GitWorkingTreeHistoryRepository from semapact.reconciliation import ReconciliationResult, RuntimeDriftStatus @@ -399,3 +401,66 @@ def test_review_deployment_rejects_approval_for_different_bundle_digest() -> Non adapter=_ExecutionAdapter(), approval=approval, ) + + +def test_deployment_approval_resolver_finds_exact_git_history_record(tmp_path) -> None: + target = DeploymentTarget( + platform="fake", + runtime_target="main.sales", + source_reference="runtime:test", + ) + service = DeploymentWorkflowService() + bundle = service.assess( + _contract(name="Orders"), + _contract(name="Orders", include_created_at=True), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=target, + adapter=_PreviewAdapter(), + ) + approval = service.approve( + bundle, + actor_reference="github:user:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + ) + repository = GitWorkingTreeHistoryRepository(tmp_path) + repository.put_approval_record(approval) + + resolved = DeploymentApprovalResolver(repository).resolve(bundle) + + assert resolved == approval + + +def test_deployment_approval_resolver_ignores_non_exact_records(tmp_path) -> None: + target = DeploymentTarget( + platform="fake", + runtime_target="main.sales", + source_reference="runtime:test", + ) + service = DeploymentWorkflowService() + bundle = service.assess( + _contract(name="Orders"), + _contract(name="Orders", include_created_at=True), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=target, + adapter=_PreviewAdapter(), + ) + repository = GitWorkingTreeHistoryRepository(tmp_path) + wrong_digest = build_approval_record( + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=bundle.version_resolution.version_resolution_id, + operation=GovernanceOperation.DEPLOY, + action=ReviewEvidenceAction.APPROVE, + actor_reference="github:user:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + scope_reference=bundle.deployment_plan.deployment_plan_id, + evidence_references=("sha256:" + ("0" * 64),), + ) + repository.put_approval_record(wrong_digest) + + assert DeploymentApprovalResolver(repository).resolve(bundle) is None From dfd810c44ff45731bbb49fd0c17b7c23dafe9cf2 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:48:56 +1000 Subject: [PATCH 082/616] test(cli): cover Git approval history fallback configuration --- tests/interfaces/test_deployment_cmd.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index b6b33b97..1e8dbdbb 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -84,6 +84,7 @@ def test_deployment_parser_exposes_only_bundle_workflow() -> None: assert deploy.deployment_command == "deploy" assert deploy.bundle == "bundle.json" assert deploy.approval == "approval.json" + assert deploy.repository_root == "." assert deploy.warehouse_id == "warehouse-1" assert deploy.output == "json" From e10f719283947f45d97688ae481f1e45fe8bb12d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:49:18 +1000 Subject: [PATCH 083/616] fix(approval): fail closed on conflicting exact review history --- .../services/deployment_approval.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/semapact/application/services/deployment_approval.py b/semapact/application/services/deployment_approval.py index a8b6ba34..e50438d3 100644 --- a/semapact/application/services/deployment_approval.py +++ b/semapact/application/services/deployment_approval.py @@ -13,9 +13,10 @@ class DeploymentApprovalResolver: """Select exact APPROVE evidence for one immutable DeploymentBundle. History persistence remains storage-only. This resolver owns the deployment-specific - selection rule: an approval must match the exact ContractOps context, DEPLOY scope, - deployment plan, and bundle digest. Multiple exact approvals are equivalent for - authorization; selection is deterministic by canonical approval identity. + selection rule: evidence must match the exact ContractOps context, DEPLOY scope, + deployment plan, and bundle digest. Conflicting exact review actions fail closed. + Multiple exact APPROVE records are equivalent for authorization; selection is + deterministic by canonical approval identity. """ def __init__(self, approvals: ApprovalHistoryRepository) -> None: @@ -29,14 +30,18 @@ def resolve(self, bundle: DeploymentBundle) -> ApprovalRecord | None: version_resolution_id=bundle.version_resolution.version_resolution_id, operation=GovernanceOperation.DEPLOY, ) - matches = tuple( + scoped = tuple( record for record in records - if record.action is ReviewEvidenceAction.APPROVE - and record.scope_reference + if record.scope_reference == bundle.deployment_plan.deployment_plan_id and bundle.bundle_digest in record.evidence_references ) - if not matches: + if not scoped: return None - return min(matches, key=lambda record: record.approval_id) + if any( + record.action is not ReviewEvidenceAction.APPROVE + for record in scoped + ): + return None + return min(scoped, key=lambda record: record.approval_id) From 33a11867bd3aba3edfcebbecc3abe470e33c731d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:49:29 +1000 Subject: [PATCH 084/616] test(approval): fail closed on conflicting Git approval history --- tests/test_deployment_workflow_service.py | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 50ce0f14..0da181fb 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -464,3 +464,46 @@ def test_deployment_approval_resolver_ignores_non_exact_records(tmp_path) -> Non repository.put_approval_record(wrong_digest) assert DeploymentApprovalResolver(repository).resolve(bundle) is None + + +def test_deployment_approval_resolver_fails_closed_on_conflicting_exact_history( + tmp_path, +) -> None: + target = DeploymentTarget( + platform="fake", + runtime_target="main.sales", + source_reference="runtime:test", + ) + service = DeploymentWorkflowService() + bundle = service.assess( + _contract(name="Orders"), + _contract(name="Orders", include_created_at=True), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=target, + adapter=_PreviewAdapter(), + ) + repository = GitWorkingTreeHistoryRepository(tmp_path) + + approved = service.approve( + bundle, + actor_reference="github:user:alice", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + ) + request_changes = build_approval_record( + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=bundle.version_resolution.version_resolution_id, + operation=GovernanceOperation.DEPLOY, + action=ReviewEvidenceAction.REQUEST_CHANGES, + actor_reference="github:user:bob", + recorded_at=datetime(2026, 9, 20, 2, 1, tzinfo=timezone.utc), + scope_reference=bundle.deployment_plan.deployment_plan_id, + evidence_references=(bundle.bundle_digest,), + ) + repository.put_approval_record(approved) + repository.put_approval_record(request_changes) + + assert DeploymentApprovalResolver(repository).resolve(bundle) is None From 61caf2cad889c9335a2bd0d6686cbc869ae8ae53 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:49:39 +1000 Subject: [PATCH 085/616] docs(cli): clarify explicit approval as optional workflow hook --- semapact/interfaces/cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index 6c151612..0ed4434c 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -492,7 +492,10 @@ def _build_parser() -> argparse.ArgumentParser: deployment_approve_parser = deployment_subparsers.add_parser( "approve", - help="Create an exact DEPLOY ApprovalRecord from a REVIEW DeploymentBundle", + help=( + "Create an explicit DEPLOY ApprovalRecord for a custom/manual workflow; " + "standard CD can resolve Git-backed approval history automatically" + ), ) deployment_approve_parser.add_argument("--bundle", required=True) deployment_approve_parser.add_argument("--actor-reference", required=True) From 4ef2aa36e8b8192a9163fad669e9d8021b5f0314 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:49:49 +1000 Subject: [PATCH 086/616] docs(deployment): make Git approval history the default REVIEW path --- docs/deployment_plans.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index d7187414..00ee82d4 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -175,7 +175,7 @@ The previous candidate-specific `DeploymentAssessment` artifact is intentionally ## CLI workflow -The deployment CLI exposes one bundle workflow: `assess` for CI/manual planning, `approve` for explicit REVIEW approval, and `deploy` for CD/manual execution. +The deployment CLI exposes one bundle workflow: `assess` for CI/manual planning and `deploy` for CD/manual execution. `approve` remains an optional hook for custom/manual workflows that want to supply an explicit ApprovalRecord. ### Assess @@ -250,12 +250,22 @@ After CI publishes the exact bundle and any required human approval is recorded, ```bash semapact deployment deploy \ --bundle ./artifacts/orders-prod.bundle.json \ - --approval ./artifacts/orders-prod.approval.json \ --warehouse-id \ --output json ``` -`--approval` is required only when the bundle carries a `GovernanceDecision(REVIEW)`. The approval must be an exact `ApprovalRecord` for DEPLOY, scoped to the bundle's `deploymentPlanId`, and its evidence references must include the exact `bundleDigest`. +For `GovernanceDecision(REVIEW)`, `deploy` first looks for an exact matching DEPLOY approval in Git-backed history under `.semapact/history` (or `--repository-root`). The record must match the exact decision/change-set/release/version context, exact `deploymentPlanId`, and exact `bundleDigest`. Conflicting exact review actions fail closed. + +Custom workflows may inject an approval artifact explicitly: + +```bash +semapact deployment deploy \ + --bundle ./artifacts/orders-prod.bundle.json \ + --approval ./artifacts/orders-prod.approval.json \ + --warehouse-id +``` + +An explicit `--approval` takes precedence over Git-history resolution. For governance-ALLOW deployments, no human approval is required. The CD workflow deliberately ignores the CI-time review preview as execution input: @@ -304,7 +314,7 @@ The adapter is also not a general Databricks infrastructure engine. Workspace, c Runtime deployment is a separate protected operation from publishing a contract release artifact. -At the canonical application boundary, callers provide an exact `DeploymentBundle` and, for REVIEW decisions, an `ApprovalRecord`. The approval must bind the exact `deploymentPlanId` and include the exact `bundleDigest` as evidence. This makes the CI artifact itself part of the approval scope rather than approving a mutable path or a SQL string. +At the canonical application boundary, REVIEW deployments require exact approval evidence bound to the `deploymentPlanId` and `bundleDigest`. The CLI can resolve that evidence from Git-backed approval history automatically, or callers can provide an explicit `ApprovalRecord` for custom workflows. This makes the CI artifact itself part of the approval scope rather than approving a mutable path or SQL string. The current low-level domain implementation still bridges this into the historical `ContractOpsAuthorization → DeploymentAuthorization` types before calling the adapter. That bridge is compatibility machinery; Data Engineers and CI/CD callers do not construct those artifacts in the bundle workflow. A future cleanup can collapse the bridge without changing the public bundle contract. From fc3298f020ce21c451e6c8b5200473d8fb08a7cf Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:49:59 +1000 Subject: [PATCH 087/616] docs(readme): default REVIEW deployment to Git approval history --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 621eb9a9..3d38072c 100644 --- a/README.md +++ b/README.md @@ -188,15 +188,16 @@ assess → read-only / no execution authority approve -→ explicit ApprovalRecord for REVIEW decisions +→ optional explicit ApprovalRecord hook for custom/manual workflows deploy -→ exact DeploymentBundle + approval when REVIEW +→ exact DeploymentBundle +→ resolve Git-backed approval history for REVIEW, unless explicit approval is supplied → fresh preview → execute → fresh verify → IN_SYNC / DRIFT / INDETERMINATE ``` -`assess` is the CI/manual planning boundary, `approve` records explicit review when required, and `deploy` is the CD/manual execution boundary. Internal planning, preview, execution, and verification primitives remain application/domain APIs rather than separate Data Engineer CLI phases. +`assess` is the CI/manual planning boundary and `deploy` is the CD/manual execution boundary. `approve` is optional: standard GitOps/CD flows can record provider approval events into Git-backed approval history and let `deploy` resolve the exact matching record automatically. Internal planning, preview, execution, and verification primitives remain application/domain APIs rather than separate Data Engineer CLI phases. The first Databricks write capability is intentionally narrow: create a missing managed Delta table, add missing nullable governed columns to an existing managed table, or perform NO_OP when the governed shape is already satisfied. Rename, existing-column type/nullability mutation, required-column addition without a safe migration strategy, DROP, and existing external/non-managed asset mutation fail closed. @@ -347,11 +348,10 @@ CD then consumes that exact artifact: ```bash semapact deployment deploy \ --bundle ./artifacts/orders-prod.bundle.json \ - --approval ./artifacts/orders-prod.approval.json \ --warehouse-id ``` -The approval is required only for REVIEW decisions and must bind the exact deployment plan and bundle digest. The deploy command re-observes runtime, derives a fresh preview, executes only that fresh plan, then performs a separate fresh convergence verification. +For REVIEW decisions, the command resolves an exact matching DEPLOY approval from Git-backed `.semapact/history` by default. Custom workflows may pass `--approval ./approval.json` explicitly instead. The approval evidence must bind the exact deployment plan and bundle digest. The deploy command re-observes runtime, derives a fresh preview, executes only that fresh plan, then performs a separate fresh convergence verification. ## Optional Dependencies From da24e372a510cf53c72cc79f4fc6a4c0a6722004 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:50:09 +1000 Subject: [PATCH 088/616] docs(approval): document deployment history resolution --- docs/approval_history.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/approval_history.md b/docs/approval_history.md index 88f3ff2d..332e2a09 100644 --- a/docs/approval_history.md +++ b/docs/approval_history.md @@ -102,6 +102,26 @@ record = service.record_review_action( Callers using another future persistence backend can provide the same `ApprovalHistoryRepository` capability without changing `ApprovalRecordService`. +## Deployment approval resolution + +For bundle-driven deployment, callers do not have to pass an approval file explicitly. + +When `semapact deployment deploy --bundle ...` receives a REVIEW bundle without `--approval`, the CLI queries Git-backed approval history under `.semapact/history/approval_records` and resolves only records that match all of the following: + +- exact `decisionId`; +- exact `changeSetId`; +- exact `releasePlanId`; +- exact `versionResolutionId`; +- operation `DEPLOY`; +- exact `deploymentPlanId` as scope; +- exact `bundleDigest` in evidence references. + +A matching `APPROVE` record can therefore be recorded by a trusted GitHub/Azure DevOps/GitLab integration before CD begins, while `deployment deploy` consumes it automatically. + +If exact scoped history contains conflicting review actions such as `REQUEST_CHANGES` or `REJECT`, resolution fails closed. SemaPact does not apply a hidden "latest review wins" rule. + +Passing `--approval ` remains supported for custom/manual workflows and takes precedence over Git-history lookup. + ## Trust boundary `ApprovalRecord` preserves review evidence; it is not an authentication credential. From 178d9c6992fef3f371044d119dd13e3483a07343 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:51:18 +1000 Subject: [PATCH 089/616] docs(deployment): separate provider approval from optional CLI injection --- docs/deployment_plans.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 00ee82d4..3a514aa2 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -243,6 +243,26 @@ The corresponding CD job downloads that exact artifact and passes it unchanged t Do not regenerate the bundle in CD. Rebuilding would create a new CI boundary and could make approval refer to material different from what CD consumes. CD is allowed—and required—to re-observe runtime, but not to replace the approved desired-state bundle. +### Review evidence intake + +The normal CI/CD path does **not** require `semapact deployment approve`. + +The review happens in the external workflow provider. A trusted integration records that provider event into SemaPact approval history, for example through the generic `semapact approval record` intake surface. That history record carries the exact DEPLOY context, `deploymentPlanId`, and `bundleDigest` evidence. + +```text +GitHub / Azure DevOps / GitLab review + ↓ +trusted workflow integration + ↓ +ApprovalRecord in .semapact/history + ↓ +CD: semapact deployment deploy --bundle ... + ↓ +automatic exact approval resolution +``` + +`semapact deployment approve --bundle ...` remains a convenience for manual/custom workflows that want to construct an explicit approval artifact directly. It is not a required stage in standard CI/CD. + ### Deploy After CI publishes the exact bundle and any required human approval is recorded, CD consumes that bundle directly: From f32b7a8ca3f56a07d31c0e1e4e555524576227a6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 13:51:36 +1000 Subject: [PATCH 090/616] style(cli): keep deployment imports ordered --- semapact/interfaces/commands/deployment_cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index d46be392..64becd2d 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -16,12 +16,12 @@ from semapact.application.models.deployment_workflow import DeploymentBundle from semapact.deployment import DeploymentTarget from semapact.exceptions import ValidationError -from semapact.interfaces.parsing import parse_iso_timestamp from semapact.interfaces.outcomes import ( ProcessOutcome, outcome_from_gate_result, outcome_from_reconciliation_status, ) +from semapact.interfaces.parsing import parse_iso_timestamp _ModelT = TypeVar("_ModelT", bound=BaseModel) From 2fc8196858ed5d71084ed1531ab45ae6ac57208d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:08:51 +1000 Subject: [PATCH 091/616] refactor(deployment): decouple plans from release-only provenance --- semapact/deployment/models.py | 305 +++++++++++++++++++++++++++++----- 1 file changed, 261 insertions(+), 44 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index fd8f5c9e..db459299 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -127,27 +127,49 @@ def _validate_desired_state_identity(self) -> DeploymentAction: class DeploymentPlan(DeploymentModel): - """Pure deterministic runtime convergence plan for one exact release snapshot.""" + """Pure deterministic runtime convergence plan for one exact source snapshot.""" deployment_plan_id: str - release_id: str = Field( - validation_alias=AliasChoices("release_id", "applied_release_id") - ) + source_snapshot_id: str contract_id: str - release_plan_id: str - released_revision_ref: str - selected_version: str + revision_ref: str + contract_version: str target: DeploymentTarget actions: tuple[DeploymentAction, ...] - plan_version: Literal["2", "3"] = "3" + release: bool = Field(strict=True) + release_id: str | None = None + release_plan_id: str | None = None + plan_version: Literal["2", "3", "4"] = "4" + + @model_validator(mode="before") + @classmethod + def _upgrade_legacy_plan_payload(cls, value): + if not isinstance(value, dict): + return value + payload = dict(value) + version = str(payload.get("plan_version", "3")) + if version not in {"2", "3"}: + return payload + + legacy_release_id = payload.get("release_id") or payload.get( + "applied_release_id" + ) + payload.setdefault("source_snapshot_id", legacy_release_id) + payload.setdefault("revision_ref", payload.get("released_revision_ref")) + payload.setdefault("contract_version", payload.get("selected_version")) + payload.setdefault("release", True) + payload.setdefault("release_id", legacy_release_id) + payload.pop("applied_release_id", None) + payload.pop("released_revision_ref", None) + payload.pop("selected_version", None) + return payload @field_validator( "deployment_plan_id", - "release_id", + "source_snapshot_id", "contract_id", - "release_plan_id", - "released_revision_ref", - "selected_version", + "revision_ref", + "contract_version", ) @classmethod def _require_plan_text(cls, value: str) -> str: @@ -156,20 +178,45 @@ def _require_plan_text(cls, value: str) -> str: raise ValueError("value must not be empty") return cleaned + @field_validator("release_id", "release_plan_id") + @classmethod + def _normalize_optional_plan_text(cls, value: str | None) -> str | None: + if value is None: + return None + cleaned = value.strip() + return cleaned or None + @model_validator(mode="after") - def _validate_action_order_and_identity(self) -> DeploymentPlan: + def _validate_action_order_and_identity(self) -> "DeploymentPlan": governed_assets = [action.governed_asset for action in self.actions] if governed_assets != sorted(governed_assets): raise ValueError("DeploymentPlan actions must be ordered by governed_asset") if len(governed_assets) != len(set(governed_assets)): raise ValueError("DeploymentPlan cannot contain duplicate governed assets") + + if self.release: + if self.release_id is None or self.release_plan_id is None: + raise ValueError( + "Release DeploymentPlan requires release_id and release_plan_id" + ) + elif self.release_id is not None or self.release_plan_id is not None: + raise ValueError( + "Non-release DeploymentPlan must not contain release provenance" + ) + validate_deployment_plan_identity(self) return self @model_serializer(mode="wrap") - def _serialize_release_identity(self, handler): - """Preserve the persisted v2 field name while v3 uses release_id.""" + def _serialize_legacy_plan(self, handler): payload = handler(self) + if self.plan_version not in {"2", "3"}: + return payload + + payload["released_revision_ref"] = payload.pop("revision_ref") + payload["selected_version"] = payload.pop("contract_version") + payload.pop("source_snapshot_id", None) + payload.pop("release", None) if self.plan_version == "2": payload["applied_release_id"] = payload.pop("release_id") return payload @@ -177,8 +224,20 @@ def _serialize_release_identity(self, handler): @property def applied_release_id(self) -> str: """Compatibility alias for callers using the v2 release field.""" + if self.release_id is None: + raise ValueError("Non-release DeploymentPlan has no applied release") return self.release_id + @property + def released_revision_ref(self) -> str: + """Compatibility alias for pre-v4 callers.""" + return self.revision_ref + + @property + def selected_version(self) -> str: + """Compatibility alias for pre-v4 callers.""" + return self.contract_version + class NativeOperation(DeploymentModel): """One immutable provider-native operation selected by an adapter preview.""" @@ -240,19 +299,41 @@ def _validate_identity(self) -> DeploymentPreview: class DeploymentAuthorization(DeploymentModel): - """Authorization bound to one exact DeploymentPlan and applied release.""" + """Authorization bound to one exact DeploymentPlan source snapshot.""" deployment_authorization_id: str - contract_ops_authorization_id: str deployment_plan_id: str - applied_release_id: str + source_snapshot_id: str allowed: bool = Field(strict=True) + authorization_kind: Literal["contractops", "governance"] = "contractops" + authorization_reference: str + authorization_version: Literal["1", "2"] = "2" + + @model_validator(mode="before") + @classmethod + def _upgrade_legacy_authorization_payload(cls, value): + if not isinstance(value, dict): + return value + payload = dict(value) + if "source_snapshot_id" in payload: + return payload + contractops_id = payload.get("contract_ops_authorization_id") + release_id = payload.get("applied_release_id") + if contractops_id is None or release_id is None: + return payload + payload["source_snapshot_id"] = release_id + payload["authorization_kind"] = "contractops" + payload["authorization_reference"] = contractops_id + payload["authorization_version"] = "1" + payload.pop("contract_ops_authorization_id", None) + payload.pop("applied_release_id", None) + return payload @field_validator( "deployment_authorization_id", - "contract_ops_authorization_id", "deployment_plan_id", - "applied_release_id", + "source_snapshot_id", + "authorization_reference", ) @classmethod def _require_authorization_text(cls, value: str) -> str: @@ -262,39 +343,112 @@ def _require_authorization_text(cls, value: str) -> str: return cleaned @model_validator(mode="after") - def _validate_identity(self) -> DeploymentAuthorization: + def _validate_identity(self) -> "DeploymentAuthorization": validate_deployment_authorization_identity(self) return self + @model_serializer(mode="wrap") + def _serialize_legacy_authorization(self, handler): + payload = handler(self) + if self.authorization_version != "1": + return payload + payload["contract_ops_authorization_id"] = payload.pop( + "authorization_reference" + ) + payload["applied_release_id"] = payload.pop("source_snapshot_id") + payload.pop("authorization_kind", None) + payload.pop("authorization_version", None) + return payload + + @property + def contract_ops_authorization_id(self) -> str: + """Compatibility accessor for ContractOps-backed authorization.""" + if self.authorization_kind != "contractops": + raise ValueError("Authorization is not backed by ContractOps") + return self.authorization_reference + + @property + def applied_release_id(self) -> str: + """Compatibility accessor for pre-v2 deployment authorization.""" + return self.source_snapshot_id + def compute_deployment_plan_id( *, + source_snapshot_id: str | None = None, release_id: str | None = None, applied_release_id: str | None = None, contract_id: str, - release_plan_id: str, - released_revision_ref: str, - selected_version: str, + revision_ref: str | None = None, + released_revision_ref: str | None = None, + contract_version: str | None = None, + selected_version: str | None = None, + release_plan_id: str | None = None, + release: bool = True, target: DeploymentTarget, actions: Sequence[DeploymentAction], - plan_version: str = "3", + plan_version: str = "4", ) -> str: - resolved_release_id = _resolve_release_id( + legacy_release_id = _resolve_optional_legacy_release_id( release_id=release_id, applied_release_id=applied_release_id, ) - if plan_version not in {"2", "3"}: + resolved_source_id = ( + source_snapshot_id.strip() + if isinstance(source_snapshot_id, str) and source_snapshot_id.strip() + else legacy_release_id + ) + if resolved_source_id is None: + raise ValueError("source_snapshot_id is required") + + resolved_revision = _resolve_text_pair( + revision_ref, + released_revision_ref, + "revision_ref", + ) + resolved_version = _resolve_text_pair( + contract_version, + selected_version, + "contract_version", + ) + + if plan_version in {"2", "3"}: + if legacy_release_id is None: + legacy_release_id = resolved_source_id + if release_plan_id is None: + raise ValueError("legacy DeploymentPlan requires release_plan_id") + release_key = "applied_release_id" if plan_version == "2" else "release_id" + return deterministic_uuid5( + SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, + { + release_key: legacy_release_id, + "contract_id": contract_id, + "release_plan_id": release_plan_id, + "released_revision_ref": resolved_revision, + "selected_version": resolved_version, + "target": target.model_dump(mode="json"), + "actions": [action.model_dump(mode="json") for action in actions], + "plan_version": plan_version, + }, + ) + + if plan_version != "4": raise ValueError(f"Unsupported DeploymentPlan version: {plan_version}") + if release and (release_id is None or release_plan_id is None): + raise ValueError("Release DeploymentPlan requires release provenance") + if not release and (release_id is not None or release_plan_id is not None): + raise ValueError("Non-release DeploymentPlan cannot contain release provenance") - release_key = "applied_release_id" if plan_version == "2" else "release_id" return deterministic_uuid5( SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, { - release_key: resolved_release_id, + "source_snapshot_id": resolved_source_id, "contract_id": contract_id, + "revision_ref": resolved_revision, + "contract_version": resolved_version, + "release": release, + "release_id": release_id, "release_plan_id": release_plan_id, - "released_revision_ref": released_revision_ref, - "selected_version": selected_version, "target": target.model_dump(mode="json"), "actions": [action.model_dump(mode="json") for action in actions], "plan_version": plan_version, @@ -302,21 +456,63 @@ def compute_deployment_plan_id( ) - def compute_deployment_authorization_id( *, - contract_ops_authorization_id: str, deployment_plan_id: str, - applied_release_id: str, + source_snapshot_id: str | None = None, + authorization_kind: str = "contractops", + authorization_reference: str | None = None, allowed: bool, + contract_ops_authorization_id: str | None = None, + applied_release_id: str | None = None, + authorization_version: str = "2", ) -> str: + if authorization_version == "1": + contractops_id = _resolve_text_pair( + authorization_reference, + contract_ops_authorization_id, + "contract_ops_authorization_id", + ) + release_id = _resolve_text_pair( + source_snapshot_id, + applied_release_id, + "applied_release_id", + ) + return deterministic_uuid5( + SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, + { + "contract_ops_authorization_id": contractops_id, + "deployment_plan_id": deployment_plan_id, + "applied_release_id": release_id, + "allowed": allowed, + }, + ) + + if authorization_version != "2": + raise ValueError( + f"Unsupported DeploymentAuthorization version: {authorization_version}" + ) + if authorization_kind not in {"contractops", "governance"}: + raise ValueError("Unsupported deployment authorization kind") + source_id = _resolve_text_pair( + source_snapshot_id, + None, + "source_snapshot_id", + ) + reference = _resolve_text_pair( + authorization_reference, + None, + "authorization_reference", + ) return deterministic_uuid5( SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, { - "contract_ops_authorization_id": contract_ops_authorization_id, + "authorization_kind": authorization_kind, + "authorization_reference": reference, "deployment_plan_id": deployment_plan_id, - "applied_release_id": applied_release_id, + "source_snapshot_id": source_id, "allowed": allowed, + "authorization_version": authorization_version, }, ) @@ -345,30 +541,49 @@ def compute_deployment_preview_id( ) -def _resolve_release_id( +def _resolve_optional_legacy_release_id( *, release_id: str | None, applied_release_id: str | None, -) -> str: +) -> str | None: values = { value.strip() for value in (release_id, applied_release_id) if isinstance(value, str) and value.strip() } + if len(values) > 1: + raise ValueError( + "release_id and applied_release_id must identify the same release" + ) + return values.pop() if values else None + + +def _resolve_text_pair( + primary: str | None, + compatibility: str | None, + field_name: str, +) -> str: + values = { + value.strip() + for value in (primary, compatibility) + if isinstance(value, str) and value.strip() + } if not values: - raise ValueError("release_id is required") + raise ValueError(f"{field_name} is required") if len(values) != 1: - raise ValueError("release_id and applied_release_id must identify the same release") + raise ValueError(f"{field_name} compatibility values do not match") return values.pop() def validate_deployment_plan_identity(plan: DeploymentPlan) -> None: expected = compute_deployment_plan_id( + source_snapshot_id=plan.source_snapshot_id, release_id=plan.release_id, contract_id=plan.contract_id, release_plan_id=plan.release_plan_id, - released_revision_ref=plan.released_revision_ref, - selected_version=plan.selected_version, + revision_ref=plan.revision_ref, + contract_version=plan.contract_version, + release=plan.release, target=plan.target, actions=plan.actions, plan_version=plan.plan_version, @@ -381,10 +596,12 @@ def validate_deployment_authorization_identity( authorization: DeploymentAuthorization, ) -> None: expected = compute_deployment_authorization_id( - contract_ops_authorization_id=authorization.contract_ops_authorization_id, deployment_plan_id=authorization.deployment_plan_id, - applied_release_id=authorization.applied_release_id, + source_snapshot_id=authorization.source_snapshot_id, + authorization_kind=authorization.authorization_kind, + authorization_reference=authorization.authorization_reference, allowed=authorization.allowed, + authorization_version=authorization.authorization_version, ) if expected != authorization.deployment_authorization_id: raise ValueError( From db95906c245b2525dba093d6d343f12b82a524fb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:09:09 +1000 Subject: [PATCH 092/616] feat(deployment): add candidate and release source snapshots --- semapact/deployment/source.py | 169 ++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 semapact/deployment/source.py diff --git a/semapact/deployment/source.py b/semapact/deployment/source.py new file mode 100644 index 00000000..e66e5571 --- /dev/null +++ b/semapact/deployment/source.py @@ -0,0 +1,169 @@ +"""Exact desired-state snapshots consumed by deployment planning.""" + +from __future__ import annotations + +import uuid + +from open_data_contract_standard.model import OpenDataContractStandard +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +from semapact.contractops import ReleaseSnapshot +from semapact.utils.deterministic import canonical_compact_json, deterministic_uuid5 + + +SEMAPACT_DEPLOYMENT_SOURCE_NAMESPACE = uuid.UUID( + "598e0c13-d9af-4cf9-a8a7-9fc7628e17c1" +) + + +class DeploymentSourceSnapshot(BaseModel): + """Immutable desired state for deployment, with optional release provenance.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + source_snapshot_id: str + contract_id: str + revision_ref: str + contract_version: str + contract_json: str + release: bool + release_id: str | None = None + release_plan_id: str | None = None + + @field_validator( + "source_snapshot_id", + "contract_id", + "revision_ref", + "contract_version", + "contract_json", + ) + @classmethod + def _require_text(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("value must not be empty") + return cleaned + + @model_validator(mode="after") + def _validate_source(self) -> "DeploymentSourceSnapshot": + contract = self.to_contract() + if str(contract.id or "").strip() != self.contract_id: + raise ValueError("DeploymentSourceSnapshot contract ID mismatch") + if str(contract.version or "").strip() != self.contract_version: + raise ValueError("DeploymentSourceSnapshot contract version mismatch") + if self.release: + if self.release_id is None or self.release_plan_id is None: + raise ValueError( + "Release deployment source requires release provenance" + ) + elif self.release_id is not None or self.release_plan_id is not None: + raise ValueError( + "Non-release deployment source cannot contain release provenance" + ) + expected = compute_deployment_source_id( + contract_id=self.contract_id, + revision_ref=self.revision_ref, + contract_version=self.contract_version, + contract_json=self.contract_json, + release=self.release, + release_id=self.release_id, + release_plan_id=self.release_plan_id, + ) + if self.source_snapshot_id != expected: + raise ValueError( + "DeploymentSourceSnapshot deterministic identity does not match content" + ) + return self + + def to_contract(self) -> OpenDataContractStandard: + return OpenDataContractStandard.model_validate_json(self.contract_json) + + +def build_candidate_deployment_source( + contract: OpenDataContractStandard, + *, + revision_ref: str, +) -> DeploymentSourceSnapshot: + """Freeze candidate desired state without calculating or changing its version.""" + if not isinstance(contract, OpenDataContractStandard): + raise TypeError( + "contract must be OpenDataContractStandard, " + f"got {type(contract).__name__}" + ) + contract_id = str(contract.id or "").strip() + contract_version = str(contract.version or "").strip() + revision = revision_ref.strip() + if not contract_id or not contract_version or not revision: + raise ValueError( + "Candidate deployment source requires contract ID, version, and revision" + ) + contract_json = canonical_compact_json( + contract.model_dump(mode="json", by_alias=True, exclude_none=True) + ) + source_id = compute_deployment_source_id( + contract_id=contract_id, + revision_ref=revision, + contract_version=contract_version, + contract_json=contract_json, + release=False, + release_id=None, + release_plan_id=None, + ) + return DeploymentSourceSnapshot( + source_snapshot_id=source_id, + contract_id=contract_id, + revision_ref=revision, + contract_version=contract_version, + contract_json=contract_json, + release=False, + ) + + +def build_release_deployment_source( + snapshot: ReleaseSnapshot, +) -> DeploymentSourceSnapshot: + """Project one exact ReleaseSnapshot into the generic deployment source seam.""" + contract_json = snapshot.released_contract_json + source_id = compute_deployment_source_id( + contract_id=snapshot.contract_id, + revision_ref=snapshot.release_revision_ref, + contract_version=snapshot.selected_version, + contract_json=contract_json, + release=True, + release_id=snapshot.release_snapshot_id, + release_plan_id=snapshot.release_plan_id, + ) + return DeploymentSourceSnapshot( + source_snapshot_id=source_id, + contract_id=snapshot.contract_id, + revision_ref=snapshot.release_revision_ref, + contract_version=snapshot.selected_version, + contract_json=contract_json, + release=True, + release_id=snapshot.release_snapshot_id, + release_plan_id=snapshot.release_plan_id, + ) + + +def compute_deployment_source_id( + *, + contract_id: str, + revision_ref: str, + contract_version: str, + contract_json: str, + release: bool, + release_id: str | None, + release_plan_id: str | None, +) -> str: + return deterministic_uuid5( + SEMAPACT_DEPLOYMENT_SOURCE_NAMESPACE, + { + "contract_id": contract_id, + "revision_ref": revision_ref, + "contract_version": contract_version, + "contract_json": contract_json, + "release": release, + "release_id": release_id, + "release_plan_id": release_plan_id, + }, + ) From b894dedca057a12e2d2443932b578621cf6e7a03 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:09:17 +1000 Subject: [PATCH 093/616] feat(deployment): plan from generic source snapshots --- semapact/deployment/planner.py | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/semapact/deployment/planner.py b/semapact/deployment/planner.py index 0251ba88..094e969d 100644 --- a/semapact/deployment/planner.py +++ b/semapact/deployment/planner.py @@ -16,6 +16,7 @@ DeploymentTarget, compute_deployment_plan_id, ) +from semapact.deployment.source import DeploymentSourceSnapshot from semapact.lifecycle.identity import normalize_identity_name from semapact.runtime import runtime_asset_specs_from_contract from semapact.utils.deterministic import canonical_compact_json @@ -75,6 +76,51 @@ def build_deployment_plan( ) + + +def build_deployment_plan_from_source( + source: DeploymentSourceSnapshot, + target: DeploymentTarget, +) -> DeploymentPlan: + """Build a v4 plan from an exact candidate or release deployment source.""" + if not isinstance(source, DeploymentSourceSnapshot): + raise TypeError( + "source must be DeploymentSourceSnapshot, " + f"got {type(source).__name__}" + ) + if not isinstance(target, DeploymentTarget): + raise TypeError( + f"target must be DeploymentTarget, got {type(target).__name__}" + ) + + contract = source.to_contract() + ordered_actions = build_deployment_actions(contract) + deployment_plan_id = compute_deployment_plan_id( + source_snapshot_id=source.source_snapshot_id, + contract_id=source.contract_id, + revision_ref=source.revision_ref, + contract_version=source.contract_version, + release=source.release, + release_id=source.release_id, + release_plan_id=source.release_plan_id, + target=target, + actions=ordered_actions, + plan_version="4", + ) + return DeploymentPlan( + deployment_plan_id=deployment_plan_id, + source_snapshot_id=source.source_snapshot_id, + contract_id=source.contract_id, + revision_ref=source.revision_ref, + contract_version=source.contract_version, + target=target, + actions=ordered_actions, + release=source.release, + release_id=source.release_id, + release_plan_id=source.release_plan_id, + plan_version="4", + ) + def build_deployment_actions( contract: OpenDataContractStandard, ) -> tuple[DeploymentAction, ...]: From 7c928d4ac614d34ac963e8b2899e774c03515f00 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:09:30 +1000 Subject: [PATCH 094/616] feat(deployment): authorize non-release candidates without approval --- semapact/deployment/authorization.py | 63 ++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index 86569645..c32b9d3b 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -15,6 +15,8 @@ compute_deployment_authorization_id, validate_deployment_plan_identity, ) +from semapact.deployment.source import DeploymentSourceSnapshot +from semapact.governance import DecisionResult, GovernanceDecision from semapact.exceptions import ReleaseValidationError from semapact.governance.gate import GovernanceOperation @@ -64,18 +66,71 @@ def authorize_deployment( "Review-based DEPLOY authorization is not scoped to this DeploymentPlan" ) + source_snapshot_id = plan.source_snapshot_id deployment_authorization_id = compute_deployment_authorization_id( - contract_ops_authorization_id=authorization.authorization_id, deployment_plan_id=plan.deployment_plan_id, - applied_release_id=release_id, + source_snapshot_id=source_snapshot_id, + authorization_kind="contractops", + authorization_reference=authorization.authorization_id, allowed=authorization.allowed, + authorization_version="2", ) return DeploymentAuthorization( deployment_authorization_id=deployment_authorization_id, - contract_ops_authorization_id=authorization.authorization_id, deployment_plan_id=plan.deployment_plan_id, - applied_release_id=release_id, + source_snapshot_id=source_snapshot_id, allowed=authorization.allowed, + authorization_kind="contractops", + authorization_reference=authorization.authorization_id, + authorization_version="2", + ) + + + +def authorize_candidate_deployment( + plan: DeploymentPlan, + source: DeploymentSourceSnapshot, + decision: GovernanceDecision, +) -> DeploymentAuthorization: + """Authorize a non-release deployment directly from governance outcome. + + Candidate deployment never creates release approval evidence. BLOCK remains + fail-closed; ALLOW and REVIEW may proceed as non-release runtime validation. + """ + if plan.release or source.release: + raise ReleaseValidationError( + "Candidate deployment authorization requires non-release plan/source" + ) + validate_deployment_plan_identity(plan) + if plan.source_snapshot_id != source.source_snapshot_id: + raise ReleaseValidationError( + "DeploymentPlan does not reference the supplied candidate source" + ) + if plan.contract_id != source.contract_id: + raise ReleaseValidationError( + "DeploymentPlan and candidate source contract IDs do not match" + ) + if decision.contract_id != source.contract_id: + raise ReleaseValidationError( + "GovernanceDecision does not match candidate deployment contract" + ) + allowed = decision.decision is not DecisionResult.BLOCK + authorization_id = compute_deployment_authorization_id( + deployment_plan_id=plan.deployment_plan_id, + source_snapshot_id=source.source_snapshot_id, + authorization_kind="governance", + authorization_reference=decision.decision_id, + allowed=allowed, + authorization_version="2", + ) + return DeploymentAuthorization( + deployment_authorization_id=authorization_id, + deployment_plan_id=plan.deployment_plan_id, + source_snapshot_id=source.source_snapshot_id, + allowed=allowed, + authorization_kind="governance", + authorization_reference=decision.decision_id, + authorization_version="2", ) From 754e7499dde7d238ef21ccf52bd068d58916e1c2 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:09:36 +1000 Subject: [PATCH 095/616] refactor(deployment): bind execution authorization to source snapshot --- semapact/deployment/orchestrator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/deployment/orchestrator.py b/semapact/deployment/orchestrator.py index 8105a659..f315fbb5 100644 --- a/semapact/deployment/orchestrator.py +++ b/semapact/deployment/orchestrator.py @@ -113,9 +113,9 @@ def execute( raise ContractOpsAuthorizationError( "DeploymentAuthorization is not bound to this DeploymentPlan" ) - if authorization.applied_release_id != plan.applied_release_id: + if authorization.source_snapshot_id != plan.source_snapshot_id: raise ContractOpsAuthorizationError( - "DeploymentAuthorization release does not match DeploymentPlan" + "DeploymentAuthorization source does not match DeploymentPlan" ) if preview.deployment_plan_id != plan.deployment_plan_id: raise ValidationError( From 9eef6e66ead835e6d67d18b309d2604eb146c6ec Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:09:43 +1000 Subject: [PATCH 096/616] refactor(deployment): export source-based planning API --- semapact/deployment/__init__.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index 53d65269..126eb7c3 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -1,7 +1,10 @@ """Provider-neutral deployment planning, orchestration and authorization boundary.""" from semapact.deployment.adapters import DeploymentAdapter -from semapact.deployment.authorization import authorize_deployment +from semapact.deployment.authorization import ( + authorize_candidate_deployment, + authorize_deployment, +) from semapact.deployment.compilers import TransitionCompiler from semapact.deployment.models import ( DeploymentAction, @@ -14,7 +17,16 @@ NativeOperationKind, ) from semapact.deployment.orchestrator import DeploymentOrchestrator -from semapact.deployment.planner import build_deployment_actions, build_deployment_plan +from semapact.deployment.planner import ( + build_deployment_actions, + build_deployment_plan, + build_deployment_plan_from_source, +) +from semapact.deployment.source import ( + DeploymentSourceSnapshot, + build_candidate_deployment_source, + build_release_deployment_source, +) from semapact.deployment.providers import ( DeploymentExecutionConfig, NativeOperationExecutor, @@ -41,8 +53,13 @@ "NativeOperationKind", "TransitionCompiler", "SchemaTransitionPlanner", + "authorize_candidate_deployment", "authorize_deployment", "build_deployment_actions", "build_deployment_plan", + "build_deployment_plan_from_source", + "build_candidate_deployment_source", + "build_release_deployment_source", + "DeploymentSourceSnapshot", "verify_deployment_convergence", ] From 534b8173ee4da7276c32ffda729f8c790ca7a924 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:09:50 +1000 Subject: [PATCH 097/616] refactor(application): plan candidate or release deployment sources --- semapact/application/services/deployment.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/semapact/application/services/deployment.py b/semapact/application/services/deployment.py index aff76147..e81b6c2e 100644 --- a/semapact/application/services/deployment.py +++ b/semapact/application/services/deployment.py @@ -8,8 +8,10 @@ DeploymentAuthorization, DeploymentPlan, DeploymentPreview, + DeploymentSourceSnapshot, DeploymentTarget, build_deployment_plan, + build_deployment_plan_from_source, ) from semapact.exceptions import ValidationError from semapact.reconciliation import ReconciliationResult @@ -20,9 +22,11 @@ class DeploymentService: def plan( self, - release: ReleaseSnapshot | AppliedContractRelease, + release: ReleaseSnapshot | AppliedContractRelease | DeploymentSourceSnapshot, target: DeploymentTarget, ) -> DeploymentPlan: + if isinstance(release, DeploymentSourceSnapshot): + return build_deployment_plan_from_source(release, target) return build_deployment_plan(release, target) def preview( From 9776f5b93ded09d1a333433a851f1dbaff83f9e4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:10:09 +1000 Subject: [PATCH 098/616] feat(deployment): make bundles candidate-or-release aware --- .../application/models/deployment_workflow.py | 181 ++++++++++++------ 1 file changed, 124 insertions(+), 57 deletions(-) diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py index 2f3a08f5..89076e62 100644 --- a/semapact/application/models/deployment_workflow.py +++ b/semapact/application/models/deployment_workflow.py @@ -13,7 +13,11 @@ ReleaseSnapshot, VersionResolution, ) -from semapact.deployment import DeploymentPlan, DeploymentPreview +from semapact.deployment import ( + DeploymentPlan, + DeploymentPreview, + DeploymentSourceSnapshot, +) from semapact.governance import GovernanceDecision from semapact.reconciliation import ReconciliationResult, RuntimeDriftStatus from semapact.utils.deterministic import canonical_compact_json @@ -27,6 +31,7 @@ class DeploymentExecutionResult(BaseModel): bundle_digest: str deployment_plan_id: str authorization_id: str + release_record_id: str | None = None fresh_preview: DeploymentPreview reconciliation: ReconciliationResult status: RuntimeDriftStatus @@ -34,135 +39,197 @@ class DeploymentExecutionResult(BaseModel): class DeploymentBundle(BaseModel): - """Immutable CI-to-CD package around existing canonical domain artifacts. - - The bundle is transport and integrity boundary only. It does not add deployment - authority; approval and authorization remain separate facts. - """ + """Immutable CI-to-CD package for candidate or formal release deployment.""" model_config = ConfigDict(frozen=True, extra="forbid") bundle_digest: str + release: bool decision: GovernanceDecision change_set: ChangeSet - release_plan: ReleasePlan - version_resolution: VersionResolution - release_snapshot: ReleaseSnapshot + deployment_source: DeploymentSourceSnapshot deployment_plan: DeploymentPlan review_preview: DeploymentPreview - bundle_version: Literal["1"] = "1" + release_plan: ReleasePlan | None = None + version_resolution: VersionResolution | None = None + release_snapshot: ReleaseSnapshot | None = None + bundle_version: Literal["2"] = "2" @model_validator(mode="after") def _validate_bundle_links(self) -> "DeploymentBundle": if self.change_set.contract_id != self.decision.contract_id: raise ValueError("DeploymentBundle decision/change-set contract mismatch") - if self.release_plan.change_set_id != self.change_set.change_set_id: - raise ValueError("DeploymentBundle release plan does not match ChangeSet") - if self.release_plan.decision_id != self.decision.decision_id: - raise ValueError("DeploymentBundle release plan does not match decision") - if self.version_resolution.release_plan_id != self.release_plan.release_plan_id: - raise ValueError( - "DeploymentBundle version resolution does not match ReleasePlan" + if self.deployment_source.contract_id != self.decision.contract_id: + raise ValueError("DeploymentBundle source/decision contract mismatch") + if self.deployment_source.release is not self.release: + raise ValueError("DeploymentBundle source release mode mismatch") + + if self.release: + if ( + self.release_plan is None + or self.version_resolution is None + or self.release_snapshot is None + ): + raise ValueError( + "Release DeploymentBundle requires release planning artifacts" + ) + if self.release_plan.change_set_id != self.change_set.change_set_id: + raise ValueError( + "DeploymentBundle release plan does not match ChangeSet" + ) + if self.release_plan.decision_id != self.decision.decision_id: + raise ValueError( + "DeploymentBundle release plan does not match decision" + ) + if ( + self.version_resolution.release_plan_id + != self.release_plan.release_plan_id + ): + raise ValueError( + "DeploymentBundle version resolution does not match ReleasePlan" + ) + if self.release_snapshot.decision_id != self.decision.decision_id: + raise ValueError("DeploymentBundle snapshot does not match decision") + if self.release_snapshot.change_set_id != self.change_set.change_set_id: + raise ValueError("DeploymentBundle snapshot does not match ChangeSet") + if ( + self.release_snapshot.release_plan_id + != self.release_plan.release_plan_id + ): + raise ValueError( + "DeploymentBundle snapshot does not match ReleasePlan" + ) + if ( + self.release_snapshot.version_resolution_id + != self.version_resolution.version_resolution_id + ): + raise ValueError( + "DeploymentBundle snapshot does not match VersionResolution" + ) + if self.deployment_source.release_id != self.release_snapshot.release_snapshot_id: + raise ValueError( + "DeploymentBundle deployment source does not match ReleaseSnapshot" + ) + elif any( + item is not None + for item in ( + self.release_plan, + self.version_resolution, + self.release_snapshot, ) - - snapshot = self.release_snapshot - if snapshot.decision_id != self.decision.decision_id: - raise ValueError("DeploymentBundle snapshot does not match decision") - if snapshot.change_set_id != self.change_set.change_set_id: - raise ValueError("DeploymentBundle snapshot does not match ChangeSet") - if snapshot.release_plan_id != self.release_plan.release_plan_id: - raise ValueError("DeploymentBundle snapshot does not match ReleasePlan") - if ( - snapshot.version_resolution_id - != self.version_resolution.version_resolution_id ): raise ValueError( - "DeploymentBundle snapshot does not match VersionResolution" + "Non-release DeploymentBundle must not contain release artifacts" ) plan = self.deployment_plan - if plan.release_id != snapshot.release_snapshot_id: - raise ValueError("DeploymentBundle plan does not match ReleaseSnapshot") - if plan.contract_id != snapshot.contract_id: - raise ValueError("DeploymentBundle plan/snapshot contract mismatch") - if plan.release_plan_id != snapshot.release_plan_id: - raise ValueError("DeploymentBundle plan/snapshot release-plan mismatch") - if plan.released_revision_ref != snapshot.release_revision_ref: - raise ValueError("DeploymentBundle plan/snapshot revision mismatch") - if plan.selected_version != snapshot.selected_version: - raise ValueError("DeploymentBundle plan/snapshot version mismatch") + if plan.source_snapshot_id != self.deployment_source.source_snapshot_id: + raise ValueError( + "DeploymentBundle plan does not match deployment source" + ) + if plan.contract_id != self.deployment_source.contract_id: + raise ValueError("DeploymentBundle plan/source contract mismatch") + if plan.revision_ref != self.deployment_source.revision_ref: + raise ValueError("DeploymentBundle plan/source revision mismatch") + if plan.contract_version != self.deployment_source.contract_version: + raise ValueError("DeploymentBundle plan/source version mismatch") + if plan.release is not self.release: + raise ValueError("DeploymentBundle plan release mode mismatch") if self.review_preview.deployment_plan_id != plan.deployment_plan_id: raise ValueError("DeploymentBundle preview does not match DeploymentPlan") expected = compute_deployment_bundle_digest( + release=self.release, decision=self.decision, change_set=self.change_set, + deployment_source=self.deployment_source, + deployment_plan=self.deployment_plan, + review_preview=self.review_preview, release_plan=self.release_plan, version_resolution=self.version_resolution, release_snapshot=self.release_snapshot, - deployment_plan=self.deployment_plan, - review_preview=self.review_preview, bundle_version=self.bundle_version, ) if self.bundle_digest != expected: raise ValueError("DeploymentBundle digest does not match content") return self - def build_deployment_bundle( *, + release: bool, decision: GovernanceDecision, change_set: ChangeSet, - release_plan: ReleasePlan, - version_resolution: VersionResolution, - release_snapshot: ReleaseSnapshot, + deployment_source: DeploymentSourceSnapshot, deployment_plan: DeploymentPlan, review_preview: DeploymentPreview, + release_plan: ReleasePlan | None = None, + version_resolution: VersionResolution | None = None, + release_snapshot: ReleaseSnapshot | None = None, ) -> DeploymentBundle: """Package exact CI material without introducing another authority artifact.""" digest = compute_deployment_bundle_digest( + release=release, decision=decision, change_set=change_set, + deployment_source=deployment_source, + deployment_plan=deployment_plan, + review_preview=review_preview, release_plan=release_plan, version_resolution=version_resolution, release_snapshot=release_snapshot, - deployment_plan=deployment_plan, - review_preview=review_preview, ) return DeploymentBundle( bundle_digest=digest, + release=release, decision=decision, change_set=change_set, + deployment_source=deployment_source, + deployment_plan=deployment_plan, + review_preview=review_preview, release_plan=release_plan, version_resolution=version_resolution, release_snapshot=release_snapshot, - deployment_plan=deployment_plan, - review_preview=review_preview, ) def compute_deployment_bundle_digest( *, + release: bool, decision: GovernanceDecision, change_set: ChangeSet, - release_plan: ReleasePlan, - version_resolution: VersionResolution, - release_snapshot: ReleaseSnapshot, + deployment_source: DeploymentSourceSnapshot, deployment_plan: DeploymentPlan, review_preview: DeploymentPreview, - bundle_version: str = "1", + release_plan: ReleasePlan | None = None, + version_resolution: VersionResolution | None = None, + release_snapshot: ReleaseSnapshot | None = None, + bundle_version: str = "2", ) -> str: - """Return a content digest suitable for CI artifact pinning and approval evidence.""" + """Return the immutable CI artifact digest for candidate or release deployment.""" payload = { "bundle_version": bundle_version, + "release": release, "decision": decision.model_dump(mode="json"), "change_set": change_set.model_dump(mode="json"), - "release_plan": release_plan.model_dump(mode="json"), - "version_resolution": version_resolution.model_dump(mode="json"), - "release_snapshot": release_snapshot.model_dump(mode="json"), + "deployment_source": deployment_source.model_dump(mode="json"), "deployment_plan": deployment_plan.model_dump(mode="json"), "review_preview": review_preview.model_dump(mode="json"), + "release_plan": ( + release_plan.model_dump(mode="json") + if release_plan is not None + else None + ), + "version_resolution": ( + version_resolution.model_dump(mode="json") + if version_resolution is not None + else None + ), + "release_snapshot": ( + release_snapshot.model_dump(mode="json") + if release_snapshot is not None + else None + ), } encoded = canonical_compact_json(payload).encode("utf-8") return f"sha256:{hashlib.sha256(encoded).hexdigest()}" From b46f4853265d9caf747e02c59c4827911808d130 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:10:39 +1000 Subject: [PATCH 099/616] feat(workflow): separate candidate deployment from formal release --- .../services/deployment_workflow.py | 162 ++++++++++++------ 1 file changed, 111 insertions(+), 51 deletions(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index f84a29e0..1c7ac357 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -12,6 +12,7 @@ build_deployment_bundle, ) from semapact.application.services.deployment import DeploymentService +from semapact.application.services.governance import GovernanceService from semapact.application.services.release_planning import ReleasePlanningService from semapact.approval import ( ApprovalRecord, @@ -26,7 +27,10 @@ from semapact.deployment import ( DeploymentAdapter, DeploymentTarget, + authorize_candidate_deployment, authorize_deployment, + build_candidate_deployment_source, + build_release_deployment_source, ) from semapact.exceptions import ContractOpsAuthorizationError, ValidationError from semapact.governance import DecisionResult, GovernanceOperation @@ -40,9 +44,11 @@ def __init__( self, *, release_planning_service: ReleasePlanningService | None = None, + governance_service: GovernanceService | None = None, deployment_service: DeploymentService | None = None, ) -> None: self._release_planning = release_planning_service or ReleasePlanningService() + self._governance = governance_service or GovernanceService() self._deployment = deployment_service or DeploymentService() def assess( @@ -55,35 +61,62 @@ def assess( candidate_revision_ref: str, target: DeploymentTarget, adapter: DeploymentAdapter, + release: bool = False, authority_reference: str | None = None, ) -> DeploymentBundle: - """Build the immutable CI handoff bundle without side-effect authorization.""" - release = self._release_planning.plan( - base_contract, - candidate_contract, - effective_date=effective_date, - base_revision_ref=base_revision_ref, - candidate_revision_ref=candidate_revision_ref, - authority_reference=authority_reference, - ) - snapshot = build_release_snapshot( - candidate_contract, - candidate_revision_ref=candidate_revision_ref, - decision=release.decision, - change_set=release.change_set, - release_plan=release.release_plan, - version_resolution=release.version_resolution, - ) - plan = self._deployment.plan(snapshot, target) + """Build an immutable candidate or formal-release CI handoff bundle.""" + if release: + planned = self._release_planning.plan( + base_contract, + candidate_contract, + effective_date=effective_date, + base_revision_ref=base_revision_ref, + candidate_revision_ref=candidate_revision_ref, + authority_reference=authority_reference, + ) + snapshot = build_release_snapshot( + candidate_contract, + candidate_revision_ref=candidate_revision_ref, + decision=planned.decision, + change_set=planned.change_set, + release_plan=planned.release_plan, + version_resolution=planned.version_resolution, + ) + source = build_release_deployment_source(snapshot) + decision = planned.decision + change_set = planned.change_set + release_plan = planned.release_plan + version_resolution = planned.version_resolution + else: + proposal = self._governance.evaluate_proposal( + base_contract, + candidate_contract, + effective_date=effective_date, + base_revision_ref=base_revision_ref, + candidate_revision_ref=candidate_revision_ref, + ) + source = build_candidate_deployment_source( + candidate_contract, + revision_ref=candidate_revision_ref, + ) + decision = proposal.decision + change_set = proposal.change_set + release_plan = None + version_resolution = None + snapshot = None + + plan = self._deployment.plan(source, target) preview = self._deployment.preview(plan, adapter=adapter) return build_deployment_bundle( - decision=release.decision, - change_set=release.change_set, - release_plan=release.release_plan, - version_resolution=release.version_resolution, - release_snapshot=snapshot, + release=release, + decision=decision, + change_set=change_set, + deployment_source=source, deployment_plan=plan, review_preview=preview, + release_plan=release_plan, + version_resolution=version_resolution, + release_snapshot=snapshot, ) def approve( @@ -95,9 +128,13 @@ def approve( comment: str | None = None, ) -> ApprovalRecord: """Record an explicit human DEPLOY approval for one exact bundle.""" + if not bundle.release: + raise ValidationError( + "Non-release deployment does not create approval records" + ) if bundle.decision.decision is not DecisionResult.REVIEW: raise ValidationError( - "Explicit deployment approval is only required for REVIEW decisions" + "Explicit deployment approval is only required for REVIEW releases" ) return build_approval_record( decision_id=bundle.decision.decision_id, @@ -126,37 +163,60 @@ def deploy( f"bundle must be DeploymentBundle, got {type(bundle).__name__}" ) - evidence = None - if bundle.decision.decision is DecisionResult.REVIEW: - if approval is None: + contract_authorization = None + if bundle.release: + if ( + bundle.release_plan is None + or bundle.version_resolution is None + or bundle.release_snapshot is None + ): + raise ValidationError( + "Release deployment bundle is missing release artifacts" + ) + evidence = None + if bundle.decision.decision is DecisionResult.REVIEW: + if approval is None: + raise ContractOpsAuthorizationError( + "Release deployment requires approval for REVIEW" + ) + if bundle.bundle_digest not in approval.evidence_references: + raise ValidationError( + "ApprovalRecord does not reference the exact DeploymentBundle digest" + ) + evidence = project_review_authorization_evidence(approval) + + contract_authorization = authorize_contract_operation( + bundle.decision, + bundle.change_set, + bundle.release_plan, + bundle.version_resolution, + GovernanceOperation.DEPLOY, + evidence=evidence, + ) + if not contract_authorization.allowed: raise ContractOpsAuthorizationError( - "Deployment requires approval for a REVIEW governance decision" + "Deployment is not authorized: " + f"{contract_authorization.reason.value}" ) - if bundle.bundle_digest not in approval.evidence_references: + deployment_authorization = authorize_deployment( + bundle.deployment_plan, + bundle.release_snapshot, + contract_authorization, + ) + else: + if approval is not None: raise ValidationError( - "ApprovalRecord does not reference the exact DeploymentBundle digest" + "Non-release deployment must not consume an ApprovalRecord" ) - evidence = project_review_authorization_evidence(approval) - - contract_authorization = authorize_contract_operation( - bundle.decision, - bundle.change_set, - bundle.release_plan, - bundle.version_resolution, - GovernanceOperation.DEPLOY, - evidence=evidence, - ) - if not contract_authorization.allowed: - raise ContractOpsAuthorizationError( - "Deployment is not authorized: " - f"{contract_authorization.reason.value}" + deployment_authorization = authorize_candidate_deployment( + bundle.deployment_plan, + bundle.deployment_source, + bundle.decision, ) - - deployment_authorization = authorize_deployment( - bundle.deployment_plan, - bundle.release_snapshot, - contract_authorization, - ) + if not deployment_authorization.allowed: + raise ContractOpsAuthorizationError( + "Candidate deployment is blocked by governance" + ) # CI-time review preview is evidence only. CD always derives a fresh preview. fresh_preview = self._deployment.preview( @@ -178,7 +238,7 @@ def deploy( return DeploymentExecutionResult( bundle_digest=bundle.bundle_digest, deployment_plan_id=bundle.deployment_plan.deployment_plan_id, - authorization_id=contract_authorization.authorization_id, + authorization_id=deployment_authorization.deployment_authorization_id, fresh_preview=fresh_preview, reconciliation=reconciliation, status=status, From b8ae1bd24e5c8b531e4c13ecd015c8a7dfb7f8fd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:10:48 +1000 Subject: [PATCH 100/616] feat(cli): add formal release mode and optional operational history --- semapact/interfaces/cli.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index 0ed4434c..49de485c 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -478,6 +478,14 @@ def _build_parser() -> argparse.ArgumentParser: help="Fallback stable runtime source identity when the candidate defines no server host", ) deployment_assess_parser.add_argument("--runtime-context", default="auto") + deployment_assess_parser.add_argument( + "--release", + action="store_true", + help=( + "Create a formal contract release: resolve a new semantic version, " + "require release approval when governance is REVIEW, and record release history" + ), + ) deployment_assess_parser.add_argument( "--bundle-out", help="Write the immutable DeploymentBundle JSON artifact to this path", @@ -540,6 +548,13 @@ def _build_parser() -> argparse.ArgumentParser: "--warehouse-id", help="Databricks SQL warehouse required when fresh operations mutate runtime", ) + deployment_deploy_parser.add_argument( + "--operational-history", + help=( + "Optional operational history backend URI: sqlite:///path/to/history.db " + "or delta:///path/to/table. Omit to disable deployment telemetry persistence." + ), + ) deployment_deploy_parser.add_argument( "--output", choices=["text", "json"], From d6902063c9b7a47f1215297a41e837299b3c4839 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:11:18 +1000 Subject: [PATCH 101/616] feat(history): add optional operational deployment event boundary --- semapact/history/operational.py | 201 ++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 semapact/history/operational.py diff --git a/semapact/history/operational.py b/semapact/history/operational.py new file mode 100644 index 00000000..8e265b33 --- /dev/null +++ b/semapact/history/operational.py @@ -0,0 +1,201 @@ +"""High-frequency operational deployment history boundary. + +Operational history is optional telemetry. It is deliberately separate from the +Git-friendly governance ledger because deployment/reconciliation events can be +high-volume in CI/CD environments. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Literal, Protocol + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +from semapact.reconciliation import RuntimeDriftStatus +from semapact.utils.deterministic import deterministic_uuid5 + + +SEMAPACT_OPERATIONAL_DEPLOYMENT_NAMESPACE = uuid.UUID( + "9c0c69eb-6294-48d3-93b7-0959a122168e" +) + + +class OperationalDeploymentEvent(BaseModel): + """One immutable deployment occurrence for optional operational persistence.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + event_id: str + bundle_digest: str + release: bool + release_record_id: str | None = None + contract_id: str + contract_version: str + revision_ref: str + deployment_plan_id: str + deployment_preview_id: str | None = None + deployment_authorization_id: str | None = None + platform: str + runtime_target: str + source_reference: str + status: Literal["SUCCEEDED", "FAILED"] + reconciliation_status: RuntimeDriftStatus | None = None + started_at: datetime + completed_at: datetime + error_message: str | None = None + + @field_validator( + "event_id", + "bundle_digest", + "contract_id", + "contract_version", + "revision_ref", + "deployment_plan_id", + "platform", + "runtime_target", + "source_reference", + ) + @classmethod + def _require_text(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("value must not be empty") + return cleaned + + @field_validator( + "release_record_id", + "deployment_preview_id", + "deployment_authorization_id", + "error_message", + ) + @classmethod + def _normalize_optional_text(cls, value: str | None) -> str | None: + if value is None: + return None + cleaned = value.strip() + return cleaned or None + + @field_validator("platform") + @classmethod + def _normalize_platform(cls, value: str) -> str: + return value.strip().casefold() + + @field_validator("started_at", "completed_at") + @classmethod + def _normalize_time(cls, value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("operational history timestamps must be timezone-aware") + return value.astimezone(timezone.utc) + + @model_validator(mode="after") + def _validate_event(self) -> "OperationalDeploymentEvent": + if self.completed_at < self.started_at: + raise ValueError("completed_at must not be earlier than started_at") + if self.status == "SUCCEEDED" and self.error_message is not None: + raise ValueError("Successful deployment event must not contain error_message") + if self.status == "FAILED" and self.error_message is None: + raise ValueError("Failed deployment event requires error_message") + expected = compute_operational_deployment_event_id( + bundle_digest=self.bundle_digest, + release=self.release, + release_record_id=self.release_record_id, + contract_id=self.contract_id, + contract_version=self.contract_version, + revision_ref=self.revision_ref, + deployment_plan_id=self.deployment_plan_id, + deployment_preview_id=self.deployment_preview_id, + deployment_authorization_id=self.deployment_authorization_id, + platform=self.platform, + runtime_target=self.runtime_target, + source_reference=self.source_reference, + status=self.status, + reconciliation_status=( + self.reconciliation_status.value + if self.reconciliation_status is not None + else None + ), + started_at=self.started_at.isoformat(), + completed_at=self.completed_at.isoformat(), + error_message=self.error_message, + ) + if self.event_id != expected: + raise ValueError( + "OperationalDeploymentEvent deterministic identity does not match content" + ) + return self + + +class OperationalHistorySink(Protocol): + """Storage-neutral sink for optional high-frequency deployment telemetry.""" + + def record_deployment(self, event: OperationalDeploymentEvent) -> None: ... + + +def build_operational_deployment_event( + *, + bundle_digest: str, + release: bool, + release_record_id: str | None, + contract_id: str, + contract_version: str, + revision_ref: str, + deployment_plan_id: str, + deployment_preview_id: str | None, + deployment_authorization_id: str | None, + platform: str, + runtime_target: str, + source_reference: str, + status: Literal["SUCCEEDED", "FAILED"], + reconciliation_status: RuntimeDriftStatus | None, + started_at: datetime, + completed_at: datetime, + error_message: str | None, +) -> OperationalDeploymentEvent: + normalized_platform = platform.strip().casefold() + event_id = compute_operational_deployment_event_id( + bundle_digest=bundle_digest, + release=release, + release_record_id=release_record_id, + contract_id=contract_id, + contract_version=contract_version, + revision_ref=revision_ref, + deployment_plan_id=deployment_plan_id, + deployment_preview_id=deployment_preview_id, + deployment_authorization_id=deployment_authorization_id, + platform=normalized_platform, + runtime_target=runtime_target, + source_reference=source_reference, + status=status, + reconciliation_status=( + reconciliation_status.value if reconciliation_status is not None else None + ), + started_at=started_at.astimezone(timezone.utc).isoformat(), + completed_at=completed_at.astimezone(timezone.utc).isoformat(), + error_message=error_message, + ) + return OperationalDeploymentEvent( + event_id=event_id, + bundle_digest=bundle_digest, + release=release, + release_record_id=release_record_id, + contract_id=contract_id, + contract_version=contract_version, + revision_ref=revision_ref, + deployment_plan_id=deployment_plan_id, + deployment_preview_id=deployment_preview_id, + deployment_authorization_id=deployment_authorization_id, + platform=normalized_platform, + runtime_target=runtime_target, + source_reference=source_reference, + status=status, + reconciliation_status=reconciliation_status, + started_at=started_at, + completed_at=completed_at, + error_message=error_message, + ) + + +def compute_operational_deployment_event_id(**payload: object) -> str: + return deterministic_uuid5(SEMAPACT_OPERATIONAL_DEPLOYMENT_NAMESPACE, payload) From 37a7b3613c54b7bf74165ba059678465cffae4ca Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:11:38 +1000 Subject: [PATCH 102/616] feat(history): add SQLite operational history adapter --- semapact/platforms/sqlite/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 semapact/platforms/sqlite/__init__.py diff --git a/semapact/platforms/sqlite/__init__.py b/semapact/platforms/sqlite/__init__.py new file mode 100644 index 00000000..3bd40c81 --- /dev/null +++ b/semapact/platforms/sqlite/__init__.py @@ -0,0 +1,5 @@ +"""SQLite operational history adapter.""" + +from semapact.platforms.sqlite.operational_history import SQLiteOperationalHistorySink + +__all__ = ["SQLiteOperationalHistorySink"] From ff43ef9c4c1ef3c35b48aec6d6fe20864bdc8135 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:11:41 +1000 Subject: [PATCH 103/616] feat(history): add SQLite operational history adapter --- .../platforms/sqlite/operational_history.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 semapact/platforms/sqlite/operational_history.py diff --git a/semapact/platforms/sqlite/operational_history.py b/semapact/platforms/sqlite/operational_history.py new file mode 100644 index 00000000..30b16328 --- /dev/null +++ b/semapact/platforms/sqlite/operational_history.py @@ -0,0 +1,70 @@ +"""SQLite persistence for optional operational deployment history.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from semapact.history.operational import OperationalDeploymentEvent +from semapact.utils.deterministic import canonical_compact_json + + +class SQLiteOperationalHistorySink: + """Persist high-frequency deployment events without touching Git history.""" + + def __init__(self, path: str | Path) -> None: + self._path = Path(path).expanduser().resolve(strict=False) + + def record_deployment(self, event: OperationalDeploymentEvent) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + payload = canonical_compact_json(event.model_dump(mode="json")) + with sqlite3.connect(self._path) as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS deployment_events ( + event_id TEXT PRIMARY KEY, + completed_at TEXT NOT NULL, + contract_id TEXT NOT NULL, + deployment_plan_id TEXT NOT NULL, + platform TEXT NOT NULL, + runtime_target TEXT NOT NULL, + status TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + """ + ) + existing = connection.execute( + "SELECT payload_json FROM deployment_events WHERE event_id = ?", + (event.event_id,), + ).fetchone() + if existing is not None: + if existing[0] != payload: + raise RuntimeError( + "Operational deployment event identity already exists " + "with different content" + ) + return + connection.execute( + """ + INSERT INTO deployment_events ( + event_id, + completed_at, + contract_id, + deployment_plan_id, + platform, + runtime_target, + status, + payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event.event_id, + event.completed_at.isoformat(), + event.contract_id, + event.deployment_plan_id, + event.platform, + event.runtime_target, + event.status, + payload, + ), + ) From 2e4b7cd59ae4e1aa184b18118e0c1dec5153b100 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:11:43 +1000 Subject: [PATCH 104/616] feat(history): add Delta operational history adapter --- semapact/platforms/delta/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 semapact/platforms/delta/__init__.py diff --git a/semapact/platforms/delta/__init__.py b/semapact/platforms/delta/__init__.py new file mode 100644 index 00000000..359e2761 --- /dev/null +++ b/semapact/platforms/delta/__init__.py @@ -0,0 +1,5 @@ +"""Delta operational history adapter.""" + +from semapact.platforms.delta.operational_history import DeltaOperationalHistorySink + +__all__ = ["DeltaOperationalHistorySink"] From d97f0bd2e0fde04cd84136f4803d8740b0a42a9f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:11:46 +1000 Subject: [PATCH 105/616] feat(history): add Delta operational history adapter --- .../platforms/delta/operational_history.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 semapact/platforms/delta/operational_history.py diff --git a/semapact/platforms/delta/operational_history.py b/semapact/platforms/delta/operational_history.py new file mode 100644 index 00000000..b8c5c2e4 --- /dev/null +++ b/semapact/platforms/delta/operational_history.py @@ -0,0 +1,48 @@ +"""Delta Lake persistence for optional operational deployment history.""" + +from __future__ import annotations + +from semapact.history.operational import OperationalDeploymentEvent +from semapact.utils.deterministic import canonical_compact_json + + +class DeltaOperationalHistorySink: + """Append deployment telemetry to a Delta table. + + Consumers should use event_id as the idempotency/deduplication key when pipeline + retry semantics can append the same immutable event more than once. + """ + + def __init__(self, table_uri: str) -> None: + cleaned = table_uri.strip() + if not cleaned: + raise ValueError("Delta operational history table URI is required") + self._table_uri = cleaned + + def record_deployment(self, event: OperationalDeploymentEvent) -> None: + try: + import pyarrow as pa + from deltalake import write_deltalake + except ImportError as exc: + raise RuntimeError( + 'Delta operational history requires: pip install "semapact[delta]"' + ) from exc + + payload = canonical_compact_json(event.model_dump(mode="json")) + table = pa.table( + { + "event_id": [event.event_id], + "completed_at": [event.completed_at.isoformat()], + "contract_id": [event.contract_id], + "deployment_plan_id": [event.deployment_plan_id], + "platform": [event.platform], + "runtime_target": [event.runtime_target], + "status": [event.status], + "payload_json": [payload], + } + ) + write_deltalake( + self._table_uri, + table, + mode="append", + ) From a268288279b98b24789b0ac41eeedcf1a11ed030 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:11:52 +1000 Subject: [PATCH 106/616] feat(history): add operational history backend registry --- semapact/history/operational_registry.py | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 semapact/history/operational_registry.py diff --git a/semapact/history/operational_registry.py b/semapact/history/operational_registry.py new file mode 100644 index 00000000..00c7a807 --- /dev/null +++ b/semapact/history/operational_registry.py @@ -0,0 +1,34 @@ +"""Composition helper for optional operational history backends.""" + +from __future__ import annotations + +from semapact.history.operational import OperationalHistorySink + + +def create_operational_history_sink( + uri: str | None, +) -> OperationalHistorySink | None: + """Create a configured operational sink, or disable persistence when omitted.""" + if uri is None or not uri.strip(): + return None + cleaned = uri.strip() + + if cleaned.startswith("sqlite:///"): + from semapact.platforms.sqlite import SQLiteOperationalHistorySink + + path = cleaned.removeprefix("sqlite:///") + if not path: + raise ValueError("SQLite operational history URI requires a path") + return SQLiteOperationalHistorySink(path) + + if cleaned.startswith("delta:///"): + from semapact.platforms.delta import DeltaOperationalHistorySink + + table_uri = cleaned.removeprefix("delta:///") + if not table_uri: + raise ValueError("Delta operational history URI requires a table path") + return DeltaOperationalHistorySink(table_uri) + + raise ValueError( + "Unsupported operational history URI. Use sqlite:///... or delta:///..." + ) From 88542d4647355cdce3dbca901a7333898468e7ba Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:12:05 +1000 Subject: [PATCH 107/616] feat(history): add formal contract release ledger record --- semapact/history/models.py | 57 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/semapact/history/models.py b/semapact/history/models.py index 2fbad67a..564b69a9 100644 --- a/semapact/history/models.py +++ b/semapact/history/models.py @@ -140,6 +140,63 @@ def _validate_provenance_pairs(self) -> ReleaseRecord: return self +class ContractReleaseRecord(HistoryModel): + """Formal immutable contract release fact stored in the governance ledger.""" + + contract_release_id: str + contract_id: str + contract_version: str + decision_id: str + change_set_id: str + release_plan_id: str + version_resolution_id: str + release_snapshot_id: str + revision_ref: str + bundle_digest: str + released_contract_json: str + approval_id: str | None = None + + @field_validator( + "contract_release_id", + "contract_id", + "contract_version", + "decision_id", + "change_set_id", + "release_plan_id", + "version_resolution_id", + "release_snapshot_id", + "revision_ref", + "bundle_digest", + "released_contract_json", + ) + @classmethod + def _require_contract_release_text(cls, value: str) -> str: + return _required_text(value) + + @field_validator("approval_id") + @classmethod + def _normalize_contract_release_approval( + cls, + value: str | None, + ) -> str | None: + return _optional_text(value) + + @model_validator(mode="after") + def _validate_contract_release_snapshot(self) -> "ContractReleaseRecord": + from open_data_contract_standard.model import OpenDataContractStandard + + contract = OpenDataContractStandard.model_validate_json( + self.released_contract_json + ) + if str(contract.id or "").strip() != self.contract_id: + raise ValueError("released contract ID does not match ContractReleaseRecord") + if str(contract.version or "").strip() != self.contract_version: + raise ValueError( + "released contract version does not match ContractReleaseRecord" + ) + return self + + class DeploymentStatus(str, Enum): """Terminal provider-execution outcome for one deployment occurrence.""" From 16ec773ab559b237c00f9de5f95b91a20c14c369 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:12:16 +1000 Subject: [PATCH 108/616] feat(history): add contract release record integrity --- semapact/history/integrity.py | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/semapact/history/integrity.py b/semapact/history/integrity.py index bb8e82e5..fd20dc0a 100644 --- a/semapact/history/integrity.py +++ b/semapact/history/integrity.py @@ -5,6 +5,7 @@ import uuid from semapact.history.models import ( + ContractReleaseRecord, DeploymentRecord, ReleaseRecord, RuntimeObservationRecord, @@ -18,6 +19,9 @@ SEMAPACT_RELEASE_RECORD_NAMESPACE = uuid.UUID( "9f750d1c-8f0a-491a-b861-8e349fc351cb" ) +SEMAPACT_CONTRACT_RELEASE_RECORD_NAMESPACE = uuid.UUID( + "0f95c8c5-4957-43f7-a38c-2756605c2df6" +) SEMAPACT_DEPLOYMENT_RECORD_NAMESPACE = uuid.UUID( "93db6770-90f0-4ac5-a185-2f21e818d18e" ) @@ -97,6 +101,60 @@ def validate_release_record_identity(record: ReleaseRecord) -> None: raise ValueError("ReleaseRecord deterministic identity does not match its content") +def compute_contract_release_record_id( + *, + contract_id: str, + contract_version: str, + decision_id: str, + change_set_id: str, + release_plan_id: str, + version_resolution_id: str, + release_snapshot_id: str, + revision_ref: str, + bundle_digest: str, + released_contract_json: str, + approval_id: str | None, +) -> str: + return deterministic_uuid5( + SEMAPACT_CONTRACT_RELEASE_RECORD_NAMESPACE, + { + "contract_id": contract_id, + "contract_version": contract_version, + "decision_id": decision_id, + "change_set_id": change_set_id, + "release_plan_id": release_plan_id, + "version_resolution_id": version_resolution_id, + "release_snapshot_id": release_snapshot_id, + "revision_ref": revision_ref, + "bundle_digest": bundle_digest, + "released_contract_json": released_contract_json, + "approval_id": approval_id, + }, + ) + + +def validate_contract_release_record_identity( + record: ContractReleaseRecord, +) -> None: + expected = compute_contract_release_record_id( + contract_id=record.contract_id, + contract_version=record.contract_version, + decision_id=record.decision_id, + change_set_id=record.change_set_id, + release_plan_id=record.release_plan_id, + version_resolution_id=record.version_resolution_id, + release_snapshot_id=record.release_snapshot_id, + revision_ref=record.revision_ref, + bundle_digest=record.bundle_digest, + released_contract_json=record.released_contract_json, + approval_id=record.approval_id, + ) + if record.contract_release_id != expected: + raise ValueError( + "ContractReleaseRecord deterministic identity does not match content" + ) + + def compute_deployment_record_id( *, release_record_id: str, From 47d08724037c6465bdd5dd3217ad3ebecf2e48ed Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:12:23 +1000 Subject: [PATCH 109/616] feat(history): add contract release ledger repository port --- semapact/history/repository.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/semapact/history/repository.py b/semapact/history/repository.py index a17f2408..d9150093 100644 --- a/semapact/history/repository.py +++ b/semapact/history/repository.py @@ -11,6 +11,7 @@ from semapact.governance.gate import GovernanceOperation from semapact.history.models import ( ChangeSetDecisionLink, + ContractReleaseRecord, DeploymentRecord, HistoryStorageIntegrityIssue, ReleaseRecord, @@ -127,6 +128,22 @@ def get_release_record_by_version( ) -> ReleaseRecord: ... +class ContractReleaseHistoryRepository(Protocol): + """Persistence capability for finalized formal contract release facts.""" + + def put_contract_release(self, record: ContractReleaseRecord) -> None: ... + def get_contract_release(self, contract_release_id: str) -> ContractReleaseRecord: ... + def list_contract_releases( + self, + contract_id: str, + ) -> tuple[ContractReleaseRecord, ...]: ... + def get_contract_release_by_version( + self, + contract_id: str, + contract_version: str, + ) -> ContractReleaseRecord: ... + + class DeploymentPlanHistoryRepository(Protocol): """Persistence capability for canonical DeploymentPlan history only.""" From 5d733cc3c76ba2ced78e64f367d970754cfe719c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:12:35 +1000 Subject: [PATCH 110/616] feat(history): persist formal contract releases in Git ledger --- semapact/platforms/git/history_repository.py | 68 ++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/semapact/platforms/git/history_repository.py b/semapact/platforms/git/history_repository.py index dfcdaf40..fbaa9582 100644 --- a/semapact/platforms/git/history_repository.py +++ b/semapact/platforms/git/history_repository.py @@ -34,6 +34,7 @@ from semapact.governance.gate import GovernanceOperation from semapact.history import ( ChangeSetDecisionLink, + ContractReleaseRecord, DeploymentRecord, HistoryConflictError, HistoryCorruptionError, @@ -45,6 +46,7 @@ RuntimeReconciliationRecord, ) from semapact.history.integrity import ( + validate_contract_release_record_identity, validate_deployment_record_identity, validate_release_record_identity, validate_runtime_observation_record_identity, @@ -119,6 +121,12 @@ class _HistoryKindSpec(Generic[T]): "release_record_id", validate_release_record_identity, ) +_CONTRACT_RELEASES = _HistoryKindSpec( + "contract_releases", + ContractReleaseRecord, + "contract_release_id", + validate_contract_release_record_identity, +) _DEPLOYMENT_PLANS = _HistoryKindSpec( "deployment_plans", DeploymentPlan, @@ -167,6 +175,7 @@ class _HistoryKindSpec(Generic[T]): _CONTRACT_REVISION_SOURCES, _RELEASE_PLANS, _RELEASE_RECORDS, + _CONTRACT_RELEASES, _DEPLOYMENT_PLANS, _DEPLOYMENT_PREVIEWS, _DEPLOYMENT_AUTHORIZATIONS, @@ -380,6 +389,65 @@ def get_release_record_by_version( ) return matches[0] + def put_contract_release(self, record: ContractReleaseRecord) -> None: + if not isinstance(record, ContractReleaseRecord): + raise TypeError( + "record must be ContractReleaseRecord, " + f"got {type(record).__name__}" + ) + validate_contract_release_record_identity(record) + for existing in self.list_contract_releases(record.contract_id): + if ( + existing.contract_version == record.contract_version + and existing.contract_release_id != record.contract_release_id + ): + raise HistoryConflictError( + "A different ContractReleaseRecord already exists for " + f"{record.contract_id!r} version {record.contract_version!r}" + ) + self._put(_CONTRACT_RELEASES, record.contract_release_id, record) + + def get_contract_release( + self, + contract_release_id: str, + ) -> ContractReleaseRecord: + return self._get(_CONTRACT_RELEASES, contract_release_id) + + def list_contract_releases( + self, + contract_id: str, + ) -> tuple[ContractReleaseRecord, ...]: + contract_id = _required_text(contract_id, "contract_id") + return tuple( + record + for record in self._list(_CONTRACT_RELEASES) + if record.contract_id == contract_id + ) + + def get_contract_release_by_version( + self, + contract_id: str, + contract_version: str, + ) -> ContractReleaseRecord: + contract_id = _required_text(contract_id, "contract_id") + contract_version = _required_text(contract_version, "contract_version") + matches = tuple( + record + for record in self.list_contract_releases(contract_id) + if record.contract_version == contract_version + ) + if not matches: + raise HistoryNotFoundError( + f"ContractReleaseRecord for {contract_id!r} " + f"version {contract_version!r} was not found" + ) + if len(matches) != 1: + raise HistoryCorruptionError( + f"Multiple ContractReleaseRecords exist for {contract_id!r} " + f"version {contract_version!r}" + ) + return matches[0] + def put_deployment_plan(self, plan: DeploymentPlan) -> None: self._put(_DEPLOYMENT_PLANS, plan.deployment_plan_id, plan) From d96ef60f67cce025f6411db146ef183c0e149dcd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:12:43 +1000 Subject: [PATCH 111/616] feat(history): export release ledger and operational history APIs --- semapact/history/__init__.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/semapact/history/__init__.py b/semapact/history/__init__.py index 6ed31053..eb42ea65 100644 --- a/semapact/history/__init__.py +++ b/semapact/history/__init__.py @@ -6,6 +6,7 @@ from semapact.history.models import ( ChangeSetDecisionLink, + ContractReleaseRecord, DeploymentRecord, DeploymentStatus, HistoryIntegrityIssueCode, @@ -18,6 +19,7 @@ ApprovalHistoryRepository, ChangeSetDecisionLinkHistoryRepository, ChangeSetHistoryRepository, + ContractReleaseHistoryRepository, ContractRevisionHistoryRepository, ContractRevisionSourceHistoryRepository, DecisionHistoryRepository, @@ -41,6 +43,8 @@ "ChangeSetDecisionLink", "ChangeSetDecisionLinkHistoryRepository", "ChangeSetHistoryRepository", + "ContractReleaseHistoryRepository", + "ContractReleaseRecord", "ContractRevisionHistoryRepository", "ContractRevisionSourceHistoryRepository", "DecisionHistoryRepository", @@ -65,3 +69,20 @@ "RuntimeReconciliationHistoryRepository", "RuntimeReconciliationRecord", ] + + +from semapact.history.operational import ( + OperationalDeploymentEvent, + OperationalHistorySink, + build_operational_deployment_event, +) +from semapact.history.operational_registry import create_operational_history_sink + +__all__.extend( + [ + "OperationalDeploymentEvent", + "OperationalHistorySink", + "build_operational_deployment_event", + "create_operational_history_sink", + ] +) From d1e510e5e713518cef4ed61625599ff4132bf3ca Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:12:56 +1000 Subject: [PATCH 112/616] feat(history): record formal contract releases from deployment bundles --- .../services/contract_release_history.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 semapact/application/services/contract_release_history.py diff --git a/semapact/application/services/contract_release_history.py b/semapact/application/services/contract_release_history.py new file mode 100644 index 00000000..f937af0f --- /dev/null +++ b/semapact/application/services/contract_release_history.py @@ -0,0 +1,80 @@ +"""Application service for formal contract release ledger persistence.""" + +from __future__ import annotations + +from semapact.approval import ApprovalRecord +from semapact.application.models.deployment_workflow import DeploymentBundle +from semapact.governance import DecisionResult, GovernanceOperation +from semapact.history import ContractReleaseHistoryRepository, ContractReleaseRecord +from semapact.history.integrity import compute_contract_release_record_id + + +class ContractReleaseHistoryService: + """Persist one formal release fact without coupling release to runtime telemetry.""" + + def __init__(self, releases: ContractReleaseHistoryRepository) -> None: + self._releases = releases + + def record_release( + self, + bundle: DeploymentBundle, + *, + approval: ApprovalRecord | None, + ) -> ContractReleaseRecord: + if not bundle.release: + raise ValueError("Non-release bundle cannot create contract release history") + if ( + bundle.release_plan is None + or bundle.version_resolution is None + or bundle.release_snapshot is None + ): + raise ValueError("Release bundle is missing release planning artifacts") + + if bundle.decision.decision is DecisionResult.REVIEW: + if approval is None: + raise ValueError("REVIEW release history requires exact approval") + if approval.operation is not GovernanceOperation.DEPLOY: + raise ValueError("Release approval must be DEPLOY-scoped") + if approval.scope_reference != bundle.deployment_plan.deployment_plan_id: + raise ValueError( + "Release approval is not scoped to exact DeploymentPlan" + ) + if bundle.bundle_digest not in approval.evidence_references: + raise ValueError( + "Release approval does not reference exact bundle digest" + ) + elif approval is not None: + raise ValueError("ALLOW release does not require ApprovalRecord") + + snapshot = bundle.release_snapshot + resolution = bundle.version_resolution + approval_id = approval.approval_id if approval is not None else None + record_id = compute_contract_release_record_id( + contract_id=snapshot.contract_id, + contract_version=snapshot.selected_version, + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=resolution.version_resolution_id, + release_snapshot_id=snapshot.release_snapshot_id, + revision_ref=snapshot.release_revision_ref, + bundle_digest=bundle.bundle_digest, + released_contract_json=snapshot.released_contract_json, + approval_id=approval_id, + ) + record = ContractReleaseRecord( + contract_release_id=record_id, + contract_id=snapshot.contract_id, + contract_version=snapshot.selected_version, + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=resolution.version_resolution_id, + release_snapshot_id=snapshot.release_snapshot_id, + revision_ref=snapshot.release_revision_ref, + bundle_digest=bundle.bundle_digest, + released_contract_json=snapshot.released_contract_json, + approval_id=approval_id, + ) + self._releases.put_contract_release(record) + return record From 38ab7b53a7ab76d438f4901a2ba1b7a8f8bbe5fe Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:13:12 +1000 Subject: [PATCH 113/616] feat(deployment): add runtime release metadata projection port --- semapact/deployment/adapters.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/semapact/deployment/adapters.py b/semapact/deployment/adapters.py index 5bfbe689..22096519 100644 --- a/semapact/deployment/adapters.py +++ b/semapact/deployment/adapters.py @@ -3,6 +3,8 @@ from __future__ import annotations from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Protocol, runtime_checkable from semapact.deployment.models import ( DeploymentAuthorization, @@ -12,6 +14,29 @@ from semapact.reconciliation import ReconciliationResult + + + +@dataclass(frozen=True) +class RuntimeReleaseMetadata: + """Release provenance projected into a runtime provider after convergence.""" + + contract_id: str + contract_version: str + contract_release_id: str + revision_ref: str + + +@runtime_checkable +class RuntimeReleaseMetadataProjector(Protocol): + """Optional provider capability for projecting formal release provenance.""" + + def project_release_metadata( + self, + plan: DeploymentPlan, + metadata: RuntimeReleaseMetadata, + ) -> None: ... + class DeploymentAdapter(ABC): """Own the complete provider-neutral deployment lifecycle entrypoints.""" From d466970f0b435f6780795f65f47f6e8f261bad0f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:13:20 +1000 Subject: [PATCH 114/616] refactor(deployment): export release metadata projection port --- semapact/deployment/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index 126eb7c3..fcf54c1e 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -1,6 +1,10 @@ """Provider-neutral deployment planning, orchestration and authorization boundary.""" -from semapact.deployment.adapters import DeploymentAdapter +from semapact.deployment.adapters import ( + DeploymentAdapter, + RuntimeReleaseMetadata, + RuntimeReleaseMetadataProjector, +) from semapact.deployment.authorization import ( authorize_candidate_deployment, authorize_deployment, @@ -41,6 +45,8 @@ "DeploymentAction", "DeploymentActionKind", "DeploymentAdapter", + "RuntimeReleaseMetadata", + "RuntimeReleaseMetadataProjector", "DeploymentAuthorization", "DeploymentExecutionConfig", "DeploymentOrchestrator", From ce80f55d9f6253ac204f5771a1b6217228e032e6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:13:33 +1000 Subject: [PATCH 115/616] feat(databricks): project formal release provenance as UC tags --- semapact/platforms/databricks/deployment.py | 66 ++++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/semapact/platforms/databricks/deployment.py b/semapact/platforms/databricks/deployment.py index 10da032d..7e021e77 100644 --- a/semapact/platforms/databricks/deployment.py +++ b/semapact/platforms/databricks/deployment.py @@ -7,7 +7,12 @@ from pydantic import field_validator -from semapact.deployment.models import NativeOperation +from semapact.deployment import RuntimeReleaseMetadata +from semapact.deployment.models import ( + DeploymentPlan, + NativeOperation, + NativeOperationKind, +) from semapact.deployment.orchestrator import DeploymentOrchestrator from semapact.deployment.providers import ( DeploymentExecutionConfig, @@ -18,10 +23,11 @@ from semapact.platforms.databricks.transition_compiler import ( DatabricksTransitionCompiler, ) +from semapact.platforms.databricks.target import parse_databricks_runtime_target from semapact.platforms.databricks.transition_planner import ( DatabricksSchemaTransitionPlanner, ) -from semapact.schema import SqlSchemaMapper +from semapact.schema import SqlSchemaMapper, validate_simple_sql_identifier _TERMINAL_STATES = {"SUCCEEDED", "FAILED", "CANCELED", "CLOSED"} @@ -146,6 +152,58 @@ def __init__( ) + def project_release_metadata( + self, + plan: DeploymentPlan, + metadata: RuntimeReleaseMetadata, + ) -> None: + """Project SemaPact release provenance into Unity Catalog table tags.""" + if not plan.release: + raise ValidationError( + "Release metadata projection requires a formal release DeploymentPlan" + ) + if metadata.contract_id != plan.contract_id: + raise ValidationError( + "Release metadata contract does not match DeploymentPlan" + ) + if metadata.contract_version != plan.contract_version: + raise ValidationError( + "Release metadata version does not match DeploymentPlan" + ) + + catalog, schema_name = parse_databricks_runtime_target( + plan.target.runtime_target + ) + validate_simple_sql_identifier(catalog, "catalog") + validate_simple_sql_identifier(schema_name, "schema") + + tags = { + "semapact_contract_id": metadata.contract_id, + "semapact_contract_version": metadata.contract_version, + "semapact_release_id": metadata.contract_release_id, + "semapact_revision": metadata.revision_ref, + } + rendered_tags = ", ".join( + f"{_sql_string(key)} = {_sql_string(value)}" + for key, value in sorted(tags.items()) + ) + for action in plan.actions: + validate_simple_sql_identifier(action.physical_name, "asset") + qualified = ".".join( + f"`{part}`" + for part in (catalog, schema_name, action.physical_name) + ) + self._executor.execute( + NativeOperation( + kind=NativeOperationKind.ALTER, + governed_asset=action.governed_asset, + statement=( + f"ALTER TABLE {qualified} SET TAGS ({rendered_tags})" + ), + ) + ) + + def _statement_state(response: Any) -> str: status = getattr(response, "status", None) state = getattr(status, "state", None) @@ -155,3 +213,7 @@ def _statement_state(response: Any) -> str: ) value = getattr(state, "value", state) return str(value).upper() + + +def _sql_string(value: str) -> str: + return "'" + value.replace("'", "''") + "'" From a14c54b33fecce4e6809b0b23de45de5ccdcdece Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:13:58 +1000 Subject: [PATCH 116/616] feat(workflow): persist formal releases and optional deployment telemetry --- .../services/deployment_workflow.py | 82 ++++++++++++++++++- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index 1c7ac357..54e74a23 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import date, datetime, timezone from open_data_contract_standard.model import OpenDataContractStandard @@ -11,6 +11,9 @@ DeploymentExecutionResult, build_deployment_bundle, ) +from semapact.application.services.contract_release_history import ( + ContractReleaseHistoryService, +) from semapact.application.services.deployment import DeploymentService from semapact.application.services.governance import GovernanceService from semapact.application.services.release_planning import ReleasePlanningService @@ -27,6 +30,8 @@ from semapact.deployment import ( DeploymentAdapter, DeploymentTarget, + RuntimeReleaseMetadata, + RuntimeReleaseMetadataProjector, authorize_candidate_deployment, authorize_deployment, build_candidate_deployment_source, @@ -34,7 +39,11 @@ ) from semapact.exceptions import ContractOpsAuthorizationError, ValidationError from semapact.governance import DecisionResult, GovernanceOperation -from semapact.reconciliation import classify_reconciliation_status +from semapact.history import ( + OperationalHistorySink, + build_operational_deployment_event, +) +from semapact.reconciliation import RuntimeDriftStatus, classify_reconciliation_status class DeploymentWorkflowService: @@ -156,13 +165,22 @@ def deploy( *, adapter: DeploymentAdapter, approval: ApprovalRecord | None = None, + release_history: ContractReleaseHistoryService | None = None, + operational_history: OperationalHistorySink | None = None, + metadata_projector: RuntimeReleaseMetadataProjector | None = None, ) -> DeploymentExecutionResult: - """Consume one exact bundle, authorize, fresh-preview, execute, and verify.""" + """Consume one exact bundle, authorize, deploy, verify, and persist configured facts.""" if not isinstance(bundle, DeploymentBundle): raise TypeError( f"bundle must be DeploymentBundle, got {type(bundle).__name__}" ) + started_at = datetime.now(timezone.utc) + release_record = None + deployment_authorization = None + fresh_preview = None + + contract_authorization = None if bundle.release: if ( @@ -218,6 +236,16 @@ def deploy( "Candidate deployment is blocked by governance" ) + if bundle.release: + if release_history is None: + raise ValidationError( + "Formal release deployment requires a release history repository" + ) + release_record = release_history.record_release( + bundle, + approval=approval, + ) + # CI-time review preview is evidence only. CD always derives a fresh preview. fresh_preview = self._deployment.preview( bundle.deployment_plan, @@ -235,13 +263,59 @@ def deploy( ) status = classify_reconciliation_status(reconciliation) - return DeploymentExecutionResult( + if ( + bundle.release + and status is RuntimeDriftStatus.IN_SYNC + and release_record is not None + and metadata_projector is not None + ): + metadata_projector.project_release_metadata( + bundle.deployment_plan, + RuntimeReleaseMetadata( + contract_id=release_record.contract_id, + contract_version=release_record.contract_version, + contract_release_id=release_record.contract_release_id, + revision_ref=release_record.revision_ref, + ), + ) + + result = DeploymentExecutionResult( bundle_digest=bundle.bundle_digest, deployment_plan_id=bundle.deployment_plan.deployment_plan_id, authorization_id=deployment_authorization.deployment_authorization_id, + release_record_id=( + release_record.contract_release_id + if release_record is not None + else None + ), fresh_preview=fresh_preview, reconciliation=reconciliation, status=status, review_preview_changed=fresh_preview != bundle.review_preview, ) + if operational_history is not None: + operational_history.record_deployment( + build_operational_deployment_event( + bundle_digest=bundle.bundle_digest, + release=bundle.release, + release_record_id=result.release_record_id, + contract_id=bundle.deployment_plan.contract_id, + contract_version=bundle.deployment_plan.contract_version, + revision_ref=bundle.deployment_plan.revision_ref, + deployment_plan_id=bundle.deployment_plan.deployment_plan_id, + deployment_preview_id=fresh_preview.deployment_preview_id, + deployment_authorization_id=( + deployment_authorization.deployment_authorization_id + ), + platform=bundle.deployment_plan.target.platform, + runtime_target=bundle.deployment_plan.target.runtime_target, + source_reference=bundle.deployment_plan.target.source_reference, + status="SUCCEEDED", + reconciliation_status=status, + started_at=started_at, + completed_at=datetime.now(timezone.utc), + error_message=None, + ) + ) + return result From eea606aa64a18f55cea5105dd976df93cc8cf110 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:14:11 +1000 Subject: [PATCH 117/616] feat(cli): compose release ledger operational history and UC metadata --- .../interfaces/commands/deployment_cmd.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 64becd2d..0c9bb990 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -79,6 +79,7 @@ def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: authority_reference=args.authority_reference, target=target, adapter=adapter, + release=args.release, ) if args.bundle_out: _write_model_artifact(args.bundle_out, bundle) @@ -157,10 +158,37 @@ def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: bundle.deployment_plan.target.platform, execution_config=execution_config, ) + + from semapact.application.services.contract_release_history import ( + ContractReleaseHistoryService, + ) + from semapact.deployment import RuntimeReleaseMetadataProjector + from semapact.history import create_operational_history_sink + from semapact.platforms.git import GitWorkingTreeHistoryRepository + + release_history = ( + ContractReleaseHistoryService( + GitWorkingTreeHistoryRepository(args.repository_root) + ) + if bundle.release + else None + ) + operational_history = create_operational_history_sink( + args.operational_history + ) + metadata_projector = ( + adapter + if isinstance(adapter, RuntimeReleaseMetadataProjector) + else None + ) + result = DeploymentWorkflowService().deploy( bundle, adapter=adapter, approval=approval, + release_history=release_history, + operational_history=operational_history, + metadata_projector=metadata_projector, ) rendered = ( _model_json(result) @@ -188,7 +216,7 @@ def _resolve_deployment_approval( from semapact.governance import DecisionResult from semapact.platforms.git import GitWorkingTreeHistoryRepository - if bundle.decision.decision is not DecisionResult.REVIEW: + if not bundle.release or bundle.decision.decision is not DecisionResult.REVIEW: return None return DeploymentApprovalResolver( @@ -256,6 +284,7 @@ def _bundle_text( lines = [ f"Contract: {bundle.release_snapshot.contract_id}@" f"{bundle.release_snapshot.selected_version}", + f"Mode: {'release' if bundle.release else 'candidate'}", f"Governance: {bundle.decision.decision.value}", f"Required bump: {bundle.decision.required_version_bump}", f"Target: {plan.target.platform}/{plan.target.runtime_target}", From e65d8f223c873bf10d26cc2e2d3dedb473b0b469 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:14:28 +1000 Subject: [PATCH 118/616] fix(databricks): render quoted UC table identifiers correctly From 0667fa281b139075874f5f8e03fdd6a41bb85411 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:14:48 +1000 Subject: [PATCH 119/616] style(deployment): remove obsolete compatibility import --- semapact/deployment/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index db459299..4f7783a2 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -8,7 +8,6 @@ from open_data_contract_standard.model import SchemaObject from pydantic import ( - AliasChoices, BaseModel, ConfigDict, Field, From 94555e429c41bf0036bf4f48b3b074a9509b9de5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:15:43 +1000 Subject: [PATCH 120/616] test(workflow): cover candidate release history and metadata semantics --- tests/test_deployment_workflow_service.py | 357 +++++++++++----------- 1 file changed, 177 insertions(+), 180 deletions(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 0da181fb..2f46c1ab 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -3,18 +3,21 @@ from datetime import datetime, timezone import pytest -from pydantic import ValidationError as PydanticValidationError - from open_data_contract_standard.model import ( OpenDataContractStandard, SchemaObject, SchemaProperty, ) +from pydantic import ValidationError as PydanticValidationError from semapact.approval import build_approval_record from semapact.application.models.deployment_workflow import DeploymentBundle +from semapact.application.services.contract_release_history import ( + ContractReleaseHistoryService, +) from semapact.application.services.deployment_approval import DeploymentApprovalResolver from semapact.application.services.deployment_workflow import DeploymentWorkflowService +from semapact.contractops import ReviewEvidenceAction from semapact.deployment import ( DeploymentAdapter, DeploymentPreview, @@ -22,7 +25,6 @@ NativeOperation, NativeOperationKind, ) -from semapact.contractops import ReviewEvidenceAction from semapact.deployment.models import compute_deployment_preview_id from semapact.exceptions import ContractOpsAuthorizationError, ValidationError from semapact.governance import DecisionResult, GovernanceOperation @@ -130,6 +132,7 @@ def preview(self, plan) -> DeploymentPreview: def execute(self, plan, preview, authorization) -> None: assert authorization.allowed is True + assert authorization.source_snapshot_id == plan.source_snapshot_id self.execute_calls += 1 self.executed_preview = preview @@ -137,12 +140,20 @@ def verify(self, plan) -> ReconciliationResult: self.verify_calls += 1 return ReconciliationResult( contract_id=plan.contract_id, - contract_version=plan.selected_version, + contract_version=plan.contract_version, observation_source_identifier=plan.target.source_reference, observation_fingerprint="obs-v2:sha256:verified", ) +class _MetadataProjector: + def __init__(self) -> None: + self.calls = [] + + def project_release_metadata(self, plan, metadata) -> None: + self.calls.append((plan, metadata)) + + def _contract( *, name: str, @@ -165,7 +176,6 @@ def _contract( required=False, ) ) - return OpenDataContractStandard( apiVersion="v3.1.0", kind="DataContract", @@ -177,53 +187,83 @@ def _contract( ) -def test_workflow_assessment_builds_content_addressed_ci_bundle() -> None: - adapter = _PreviewAdapter() - target = DeploymentTarget( +def _target() -> DeploymentTarget: + return DeploymentTarget( platform="fake", runtime_target="main.sales", source_reference="runtime:test", ) + +def _release_history(tmp_path) -> ContractReleaseHistoryService: + return ContractReleaseHistoryService( + GitWorkingTreeHistoryRepository(tmp_path) + ) + + +def _review_release_bundle() -> DeploymentBundle: + return DeploymentWorkflowService().assess( + _contract(name="Orders"), + _contract(name="Orders", include_created_at=True), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=_target(), + adapter=_PreviewAdapter(), + release=True, + ) + + +def test_candidate_assessment_does_not_calculate_release_version() -> None: bundle = DeploymentWorkflowService().assess( _contract(name="Orders old"), _contract(name="Orders new"), effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", - target=target, - adapter=adapter, + target=_target(), + adapter=_PreviewAdapter(), ) - assert adapter.preview_calls == 1 - assert bundle.change_set.base_revision_ref == "git:base" - assert bundle.release_plan.release_revision_ref == "git:candidate" - assert bundle.release_snapshot.release_revision_ref == "git:candidate" + assert bundle.release is False + assert bundle.release_plan is None + assert bundle.version_resolution is None + assert bundle.release_snapshot is None + assert bundle.deployment_source.release is False + assert bundle.deployment_source.contract_version == "1.2.3" + assert bundle.deployment_plan.release is False + assert bundle.deployment_plan.contract_version == "1.2.3" + assert bundle.deployment_plan.plan_version == "4" + + +def test_release_assessment_resolves_version_once_and_binds_source() -> None: + bundle = _review_release_bundle() + + assert bundle.release is True + assert bundle.release_plan is not None + assert bundle.version_resolution is not None + assert bundle.release_snapshot is not None + assert bundle.deployment_source.release is True assert ( - bundle.deployment_plan.release_id + bundle.deployment_source.release_id == bundle.release_snapshot.release_snapshot_id ) - assert bundle.deployment_plan.plan_version == "3" - assert bundle.review_preview.deployment_plan_id == bundle.deployment_plan.deployment_plan_id - assert bundle.review_preview.operations[0].kind is NativeOperationKind.CREATE - assert bundle.bundle_digest.startswith("sha256:") - assert not hasattr(bundle, "authorization") - assert not hasattr(bundle.release_snapshot, "authorization_id") + assert ( + bundle.deployment_source.contract_version + == bundle.version_resolution.selected_version + ) + assert bundle.deployment_plan.release is True + assert bundle.deployment_plan.plan_version == "4" def test_same_inputs_produce_same_bundle_digest() -> None: - target = DeploymentTarget( - platform="fake", - runtime_target="main.sales", - source_reference="runtime:test", - ) first = DeploymentWorkflowService().assess( _contract(name="Orders old"), _contract(name="Orders new"), effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", - target=target, + target=_target(), adapter=_PreviewAdapter(), ) second = DeploymentWorkflowService().assess( @@ -232,27 +272,36 @@ def test_same_inputs_produce_same_bundle_digest() -> None: effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", - target=target, + target=_target(), adapter=_PreviewAdapter(), ) - - assert first.bundle_digest == second.bundle_digest assert first == second + assert first.bundle_digest == second.bundle_digest -def test_bundle_rehydration_fails_closed_when_digest_is_tampered() -> None: - target = DeploymentTarget( - platform="fake", - runtime_target="main.sales", - source_reference="runtime:test", +def test_release_mode_changes_bundle_identity() -> None: + candidate = DeploymentWorkflowService().assess( + _contract(name="Orders"), + _contract(name="Orders", include_created_at=True), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=_target(), + adapter=_PreviewAdapter(), ) + release = _review_release_bundle() + + assert candidate.bundle_digest != release.bundle_digest + + +def test_bundle_rehydration_fails_closed_when_digest_is_tampered() -> None: bundle = DeploymentWorkflowService().assess( _contract(name="Orders old"), _contract(name="Orders new"), effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", - target=target, + target=_target(), adapter=_PreviewAdapter(), ) payload = bundle.model_dump(mode="json") @@ -262,12 +311,27 @@ def test_bundle_rehydration_fails_closed_when_digest_is_tampered() -> None: DeploymentBundle.model_validate(payload) -def test_cd_uses_fresh_preview_not_ci_review_preview() -> None: - target = DeploymentTarget( - platform="fake", - runtime_target="main.sales", - source_reference="runtime:test", +def test_candidate_review_deployment_needs_no_approval_or_release_history() -> None: + service = DeploymentWorkflowService() + bundle = service.assess( + _contract(name="Orders"), + _contract(name="Orders", include_created_at=True), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=_target(), + adapter=_PreviewAdapter(), ) + assert bundle.decision.decision is DecisionResult.REVIEW + assert bundle.release is False + + result = service.deploy(bundle, adapter=_ExecutionAdapter()) + + assert result.release_record_id is None + assert result.status is RuntimeDriftStatus.IN_SYNC + + +def test_cd_uses_fresh_preview_not_ci_review_preview() -> None: service = DeploymentWorkflowService() bundle = service.assess( _contract(name="Orders old"), @@ -275,13 +339,11 @@ def test_cd_uses_fresh_preview_not_ci_review_preview() -> None: effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", - target=target, + target=_target(), adapter=_PreviewAdapter(), ) - assert bundle.decision.decision is DecisionResult.ALLOW - assert bundle.review_preview.operations[0].kind is NativeOperationKind.CREATE - adapter = _ExecutionAdapter() + result = service.deploy(bundle, adapter=adapter) assert adapter.preview_calls == 1 @@ -290,15 +352,9 @@ def test_cd_uses_fresh_preview_not_ci_review_preview() -> None: assert adapter.executed_preview is result.fresh_preview assert result.fresh_preview.operations[0].kind is NativeOperationKind.NO_OP assert result.review_preview_changed is True - assert result.status is RuntimeDriftStatus.IN_SYNC -def test_workflow_approval_is_derived_from_exact_review_bundle() -> None: - target = DeploymentTarget( - platform="fake", - runtime_target="main.sales", - source_reference="runtime:test", - ) +def test_non_release_bundle_rejects_manual_approval() -> None: service = DeploymentWorkflowService() bundle = service.assess( _contract(name="Orders"), @@ -306,82 +362,95 @@ def test_workflow_approval_is_derived_from_exact_review_bundle() -> None: effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", - target=target, + target=_target(), adapter=_PreviewAdapter(), ) + with pytest.raises(ValidationError, match="Non-release"): + service.approve( + bundle, + actor_reference="human:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + ) + + +def test_release_approval_is_derived_from_exact_bundle() -> None: + service = DeploymentWorkflowService() + bundle = _review_release_bundle() + approval = service.approve( bundle, actor_reference="human:reviewer", recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), - comment="Approved for production", ) assert approval.operation is GovernanceOperation.DEPLOY assert approval.action is ReviewEvidenceAction.APPROVE assert approval.scope_reference == bundle.deployment_plan.deployment_plan_id assert approval.evidence_references == (bundle.bundle_digest,) - assert approval.decision_id == bundle.decision.decision_id -def test_review_deployment_requires_approval_bound_to_plan_and_bundle_digest() -> None: - target = DeploymentTarget( - platform="fake", - runtime_target="main.sales", - source_reference="runtime:test", - ) +def test_review_release_requires_approval_and_records_formal_release(tmp_path) -> None: service = DeploymentWorkflowService() - bundle = service.assess( - _contract(name="Orders"), - _contract(name="Orders", include_created_at=True), - effective_date="2026-09-20", - base_revision_ref="git:base", - candidate_revision_ref="git:candidate", - target=target, - adapter=_PreviewAdapter(), - ) - assert bundle.decision.decision is DecisionResult.REVIEW + bundle = _review_release_bundle() with pytest.raises(ContractOpsAuthorizationError, match="requires approval"): - service.deploy(bundle, adapter=_ExecutionAdapter()) + service.deploy( + bundle, + adapter=_ExecutionAdapter(), + release_history=_release_history(tmp_path), + ) - approval = build_approval_record( - decision_id=bundle.decision.decision_id, - change_set_id=bundle.change_set.change_set_id, - release_plan_id=bundle.release_plan.release_plan_id, - version_resolution_id=bundle.version_resolution.version_resolution_id, - operation=GovernanceOperation.DEPLOY, - action=ReviewEvidenceAction.APPROVE, + approval = service.approve( + bundle, + actor_reference="human:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + ) + result = service.deploy( + bundle, + adapter=_ExecutionAdapter(), + approval=approval, + release_history=_release_history(tmp_path), + ) + + assert result.release_record_id is not None + repository = GitWorkingTreeHistoryRepository(tmp_path) + record = repository.get_contract_release(result.release_record_id) + assert record.contract_version == bundle.version_resolution.selected_version + assert record.bundle_digest == bundle.bundle_digest + assert record.approval_id == approval.approval_id + + +def test_release_projects_metadata_only_after_in_sync(tmp_path) -> None: + service = DeploymentWorkflowService() + bundle = _review_release_bundle() + approval = service.approve( + bundle, actor_reference="human:reviewer", recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), - scope_reference=bundle.deployment_plan.deployment_plan_id, - evidence_references=(bundle.bundle_digest,), ) + projector = _MetadataProjector() result = service.deploy( bundle, adapter=_ExecutionAdapter(), approval=approval, + release_history=_release_history(tmp_path), + metadata_projector=projector, ) + assert result.status is RuntimeDriftStatus.IN_SYNC + assert len(projector.calls) == 1 + plan, metadata = projector.calls[0] + assert metadata.contract_version == plan.contract_version + assert metadata.contract_release_id == result.release_record_id -def test_review_deployment_rejects_approval_for_different_bundle_digest() -> None: - target = DeploymentTarget( - platform="fake", - runtime_target="main.sales", - source_reference="runtime:test", - ) +def test_review_release_rejects_approval_for_different_bundle_digest(tmp_path) -> None: service = DeploymentWorkflowService() - bundle = service.assess( - _contract(name="Orders"), - _contract(name="Orders", include_created_at=True), - effective_date="2026-09-20", - base_revision_ref="git:base", - candidate_revision_ref="git:candidate", - target=target, - adapter=_PreviewAdapter(), - ) + bundle = _review_release_bundle() + assert bundle.release_plan is not None + assert bundle.version_resolution is not None approval = build_approval_record( decision_id=bundle.decision.decision_id, change_set_id=bundle.change_set.change_set_id, @@ -400,25 +469,13 @@ def test_review_deployment_rejects_approval_for_different_bundle_digest() -> Non bundle, adapter=_ExecutionAdapter(), approval=approval, + release_history=_release_history(tmp_path), ) -def test_deployment_approval_resolver_finds_exact_git_history_record(tmp_path) -> None: - target = DeploymentTarget( - platform="fake", - runtime_target="main.sales", - source_reference="runtime:test", - ) +def test_deployment_approval_resolver_finds_exact_release_record(tmp_path) -> None: service = DeploymentWorkflowService() - bundle = service.assess( - _contract(name="Orders"), - _contract(name="Orders", include_created_at=True), - effective_date="2026-09-20", - base_revision_ref="git:base", - candidate_revision_ref="git:candidate", - target=target, - adapter=_PreviewAdapter(), - ) + bundle = _review_release_bundle() approval = service.approve( bundle, actor_reference="github:user:reviewer", @@ -427,83 +484,23 @@ def test_deployment_approval_resolver_finds_exact_git_history_record(tmp_path) - repository = GitWorkingTreeHistoryRepository(tmp_path) repository.put_approval_record(approval) - resolved = DeploymentApprovalResolver(repository).resolve(bundle) - - assert resolved == approval + assert DeploymentApprovalResolver(repository).resolve(bundle) == approval -def test_deployment_approval_resolver_ignores_non_exact_records(tmp_path) -> None: - target = DeploymentTarget( - platform="fake", - runtime_target="main.sales", - source_reference="runtime:test", - ) - service = DeploymentWorkflowService() - bundle = service.assess( - _contract(name="Orders"), - _contract(name="Orders", include_created_at=True), - effective_date="2026-09-20", - base_revision_ref="git:base", - candidate_revision_ref="git:candidate", - target=target, - adapter=_PreviewAdapter(), - ) - repository = GitWorkingTreeHistoryRepository(tmp_path) - wrong_digest = build_approval_record( - decision_id=bundle.decision.decision_id, - change_set_id=bundle.change_set.change_set_id, - release_plan_id=bundle.release_plan.release_plan_id, - version_resolution_id=bundle.version_resolution.version_resolution_id, - operation=GovernanceOperation.DEPLOY, - action=ReviewEvidenceAction.APPROVE, - actor_reference="github:user:reviewer", - recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), - scope_reference=bundle.deployment_plan.deployment_plan_id, - evidence_references=("sha256:" + ("0" * 64),), - ) - repository.put_approval_record(wrong_digest) - - assert DeploymentApprovalResolver(repository).resolve(bundle) is None - - -def test_deployment_approval_resolver_fails_closed_on_conflicting_exact_history( - tmp_path, -) -> None: - target = DeploymentTarget( - platform="fake", - runtime_target="main.sales", - source_reference="runtime:test", - ) - service = DeploymentWorkflowService() - bundle = service.assess( +def test_deployment_approval_resolver_ignores_candidate_bundle(tmp_path) -> None: + bundle = DeploymentWorkflowService().assess( _contract(name="Orders"), _contract(name="Orders", include_created_at=True), effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", - target=target, + target=_target(), adapter=_PreviewAdapter(), ) - repository = GitWorkingTreeHistoryRepository(tmp_path) - approved = service.approve( - bundle, - actor_reference="github:user:alice", - recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), - ) - request_changes = build_approval_record( - decision_id=bundle.decision.decision_id, - change_set_id=bundle.change_set.change_set_id, - release_plan_id=bundle.release_plan.release_plan_id, - version_resolution_id=bundle.version_resolution.version_resolution_id, - operation=GovernanceOperation.DEPLOY, - action=ReviewEvidenceAction.REQUEST_CHANGES, - actor_reference="github:user:bob", - recorded_at=datetime(2026, 9, 20, 2, 1, tzinfo=timezone.utc), - scope_reference=bundle.deployment_plan.deployment_plan_id, - evidence_references=(bundle.bundle_digest,), + assert ( + DeploymentApprovalResolver( + GitWorkingTreeHistoryRepository(tmp_path) + ).resolve(bundle) + is None ) - repository.put_approval_record(approved) - repository.put_approval_record(request_changes) - - assert DeploymentApprovalResolver(repository).resolve(bundle) is None From 831301e561e6e88d254bd0be5c789eba48892fb4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:15:49 +1000 Subject: [PATCH 121/616] fix(approval): ignore non-release deployment bundles --- semapact/application/services/deployment_approval.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/semapact/application/services/deployment_approval.py b/semapact/application/services/deployment_approval.py index e50438d3..612e483b 100644 --- a/semapact/application/services/deployment_approval.py +++ b/semapact/application/services/deployment_approval.py @@ -23,6 +23,10 @@ def __init__(self, approvals: ApprovalHistoryRepository) -> None: self._approvals = approvals def resolve(self, bundle: DeploymentBundle) -> ApprovalRecord | None: + if not bundle.release: + return None + if bundle.release_plan is None or bundle.version_resolution is None: + return None records = self._approvals.list_approval_records_for_context( decision_id=bundle.decision.decision_id, change_set_id=bundle.change_set.change_set_id, From 47a46af188a74885db377e33265aa8effed418b9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:16:02 +1000 Subject: [PATCH 122/616] test(history): cover opt-in SQLite and Delta operational backends --- tests/test_operational_history.py | 65 +++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/test_operational_history.py diff --git a/tests/test_operational_history.py b/tests/test_operational_history.py new file mode 100644 index 00000000..5278432d --- /dev/null +++ b/tests/test_operational_history.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timezone + +from semapact.history import ( + build_operational_deployment_event, + create_operational_history_sink, +) +from semapact.platforms.delta import DeltaOperationalHistorySink +from semapact.platforms.sqlite import SQLiteOperationalHistorySink +from semapact.reconciliation import RuntimeDriftStatus + + +def _event(): + return build_operational_deployment_event( + bundle_digest="sha256:" + ("a" * 64), + release=False, + release_record_id=None, + contract_id="orders-product", + contract_version="1.2.3", + revision_ref="git:abc123", + deployment_plan_id="plan-1", + deployment_preview_id="preview-1", + deployment_authorization_id="auth-1", + platform="databricks", + runtime_target="main.sales", + source_reference="https://workspace.example", + status="SUCCEEDED", + reconciliation_status=RuntimeDriftStatus.IN_SYNC, + started_at=datetime(2026, 9, 20, 1, 0, tzinfo=timezone.utc), + completed_at=datetime(2026, 9, 20, 1, 1, tzinfo=timezone.utc), + error_message=None, + ) + + +def test_operational_history_is_disabled_when_not_configured() -> None: + assert create_operational_history_sink(None) is None + assert create_operational_history_sink("") is None + + +def test_sqlite_operational_history_is_idempotent(tmp_path) -> None: + database = tmp_path / "operational.db" + sink = create_operational_history_sink(f"sqlite:///{database}") + + assert isinstance(sink, SQLiteOperationalHistorySink) + event = _event() + sink.record_deployment(event) + sink.record_deployment(event) + + with sqlite3.connect(database) as connection: + rows = connection.execute( + "SELECT event_id, payload_json FROM deployment_events" + ).fetchall() + + assert len(rows) == 1 + assert rows[0][0] == event.event_id + assert json.loads(rows[0][1])["deployment_plan_id"] == "plan-1" + + +def test_delta_operational_history_adapter_is_lazy() -> None: + sink = create_operational_history_sink("delta:///tmp/semapact-history") + + assert isinstance(sink, DeltaOperationalHistorySink) From 8434ba599c7819081a2d9cf321894bea25550d18 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:16:13 +1000 Subject: [PATCH 123/616] test(cli): cover release mode and operational history options --- tests/interfaces/test_deployment_cmd.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index 1e8dbdbb..29b50803 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -36,6 +36,7 @@ def test_deployment_parser_exposes_only_bundle_workflow() -> None: "production", "--bundle-out", "bundle.json", + "--release", "--output", "json", ] @@ -66,6 +67,8 @@ def test_deployment_parser_exposes_only_bundle_workflow() -> None: "approval.json", "--warehouse-id", "warehouse-1", + "--operational-history", + "sqlite:///history.db", "--output", "json", ] @@ -74,6 +77,7 @@ def test_deployment_parser_exposes_only_bundle_workflow() -> None: assert assess.deployment_command == "assess" assert assess.server == "production" assert assess.bundle_out == "bundle.json" + assert assess.release is True assert assess.output == "json" assert approve.deployment_command == "approve" @@ -86,6 +90,7 @@ def test_deployment_parser_exposes_only_bundle_workflow() -> None: assert deploy.approval == "approval.json" assert deploy.repository_root == "." assert deploy.warehouse_id == "warehouse-1" + assert deploy.operational_history == "sqlite:///history.db" assert deploy.output == "json" From c1fdd4cea3a3e4dbb4ec1e48abe194f5324cf042 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:16:46 +1000 Subject: [PATCH 124/616] fix(deployment): preserve legacy plan construction defaults --- semapact/deployment/models.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index 4f7783a2..1e3dca5f 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -146,7 +146,21 @@ def _upgrade_legacy_plan_payload(cls, value): if not isinstance(value, dict): return value payload = dict(value) - version = str(payload.get("plan_version", "3")) + raw_version = payload.get("plan_version") + if raw_version is None: + if "applied_release_id" in payload: + version = "2" + elif ( + "release_id" in payload + or "released_revision_ref" in payload + or "selected_version" in payload + ): + version = "3" + else: + return payload + payload["plan_version"] = version + else: + version = str(raw_version) if version not in {"2", "3"}: return payload @@ -386,7 +400,7 @@ def compute_deployment_plan_id( release: bool = True, target: DeploymentTarget, actions: Sequence[DeploymentAction], - plan_version: str = "4", + plan_version: str = "3", ) -> str: legacy_release_id = _resolve_optional_legacy_release_id( release_id=release_id, From c2eef3fb6cc7230eb79aca731a1d6242ca3ed426 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:18:04 +1000 Subject: [PATCH 125/616] fix(deployment): auto-detect legacy identity versions --- semapact/deployment/models.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index 1e3dca5f..37ebcee5 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -400,8 +400,16 @@ def compute_deployment_plan_id( release: bool = True, target: DeploymentTarget, actions: Sequence[DeploymentAction], - plan_version: str = "3", + plan_version: str | None = None, ) -> str: + if plan_version is None: + if applied_release_id is not None: + plan_version = "2" + elif source_snapshot_id is not None and release is False: + plan_version = "4" + else: + plan_version = "3" + legacy_release_id = _resolve_optional_legacy_release_id( release_id=release_id, applied_release_id=applied_release_id, @@ -478,8 +486,16 @@ def compute_deployment_authorization_id( allowed: bool, contract_ops_authorization_id: str | None = None, applied_release_id: str | None = None, - authorization_version: str = "2", + authorization_version: str | None = None, ) -> str: + if authorization_version is None: + authorization_version = ( + "1" + if contract_ops_authorization_id is not None + or applied_release_id is not None + else "2" + ) + if authorization_version == "1": contractops_id = _resolve_text_pair( authorization_reference, From a8b12bb62269c850d04dfd238dab2e19e79bc80c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:18:24 +1000 Subject: [PATCH 126/616] fix(cli): render candidate bundles without release artifacts --- semapact/interfaces/commands/deployment_cmd.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 0c9bb990..1cea1381 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -282,11 +282,15 @@ def _bundle_text( plan = bundle.deployment_plan preview = bundle.review_preview lines = [ - f"Contract: {bundle.release_snapshot.contract_id}@" - f"{bundle.release_snapshot.selected_version}", + f"Contract: {bundle.deployment_source.contract_id}@" + f"{bundle.deployment_source.contract_version}", f"Mode: {'release' if bundle.release else 'candidate'}", f"Governance: {bundle.decision.decision.value}", - f"Required bump: {bundle.decision.required_version_bump}", + ( + f"Required bump: {bundle.decision.required_version_bump}" + if bundle.release + else "Required bump: not calculated" + ), f"Target: {plan.target.platform}/{plan.target.runtime_target}", f"Deployment plan: {plan.deployment_plan_id}", f"Bundle digest: {bundle.bundle_digest}", @@ -317,6 +321,11 @@ def _deployment_result_text(result) -> str: f"Bundle digest: {result.bundle_digest}", f"Deployment plan: {result.deployment_plan_id}", f"Authorization: {result.authorization_id}", + *( + [f"Contract release: {result.release_record_id}"] + if result.release_record_id is not None + else [] + ), "Provider execution: SUCCEEDED", f"Verification: {result.status.value}", ( From 279d3caeeca4a601efdf892d52d6779ed0c773f5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:18:41 +1000 Subject: [PATCH 127/616] feat(history): record configured deployment failures --- .../services/deployment_workflow.py | 103 ++++++++++++------ 1 file changed, 71 insertions(+), 32 deletions(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index 54e74a23..78757918 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -175,11 +175,8 @@ def deploy( f"bundle must be DeploymentBundle, got {type(bundle).__name__}" ) - started_at = datetime.now(timezone.utc) release_record = None deployment_authorization = None - fresh_preview = None - contract_authorization = None if bundle.release: @@ -247,38 +244,80 @@ def deploy( ) # CI-time review preview is evidence only. CD always derives a fresh preview. - fresh_preview = self._deployment.preview( - bundle.deployment_plan, - adapter=adapter, - ) - self._deployment.execute( - bundle.deployment_plan, - fresh_preview, - deployment_authorization, - adapter=adapter, - ) - reconciliation = self._deployment.verify( - bundle.deployment_plan, - adapter=adapter, - ) - status = classify_reconciliation_status(reconciliation) - - if ( - bundle.release - and status is RuntimeDriftStatus.IN_SYNC - and release_record is not None - and metadata_projector is not None - ): - metadata_projector.project_release_metadata( + started_at = datetime.now(timezone.utc) + fresh_preview = None + reconciliation = None + status = None + try: + fresh_preview = self._deployment.preview( + bundle.deployment_plan, + adapter=adapter, + ) + self._deployment.execute( + bundle.deployment_plan, + fresh_preview, + deployment_authorization, + adapter=adapter, + ) + reconciliation = self._deployment.verify( bundle.deployment_plan, - RuntimeReleaseMetadata( - contract_id=release_record.contract_id, - contract_version=release_record.contract_version, - contract_release_id=release_record.contract_release_id, - revision_ref=release_record.revision_ref, - ), + adapter=adapter, ) + status = classify_reconciliation_status(reconciliation) + + if ( + bundle.release + and status is RuntimeDriftStatus.IN_SYNC + and release_record is not None + and metadata_projector is not None + ): + metadata_projector.project_release_metadata( + bundle.deployment_plan, + RuntimeReleaseMetadata( + contract_id=release_record.contract_id, + contract_version=release_record.contract_version, + contract_release_id=release_record.contract_release_id, + revision_ref=release_record.revision_ref, + ), + ) + except Exception as exc: + if operational_history is not None: + operational_history.record_deployment( + build_operational_deployment_event( + bundle_digest=bundle.bundle_digest, + release=bundle.release, + release_record_id=( + release_record.contract_release_id + if release_record is not None + else None + ), + contract_id=bundle.deployment_plan.contract_id, + contract_version=bundle.deployment_plan.contract_version, + revision_ref=bundle.deployment_plan.revision_ref, + deployment_plan_id=bundle.deployment_plan.deployment_plan_id, + deployment_preview_id=( + fresh_preview.deployment_preview_id + if fresh_preview is not None + else None + ), + deployment_authorization_id=( + deployment_authorization.deployment_authorization_id + ), + platform=bundle.deployment_plan.target.platform, + runtime_target=bundle.deployment_plan.target.runtime_target, + source_reference=bundle.deployment_plan.target.source_reference, + status="FAILED", + reconciliation_status=status, + started_at=started_at, + completed_at=datetime.now(timezone.utc), + error_message=str(exc) or type(exc).__name__, + ) + ) + raise + assert fresh_preview is not None + assert reconciliation is not None + assert status is not None result = DeploymentExecutionResult( bundle_digest=bundle.bundle_digest, deployment_plan_id=bundle.deployment_plan.deployment_plan_id, From d312416a71ab75552e2e628ed5c0460b475cc01d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:18:49 +1000 Subject: [PATCH 128/616] test(databricks): remove deleted low-level deployment CLI roundtrip --- tests/test_databricks_convergence_e2e.py | 83 ------------------------ 1 file changed, 83 deletions(-) diff --git a/tests/test_databricks_convergence_e2e.py b/tests/test_databricks_convergence_e2e.py index b3fb0218..e43ce188 100644 --- a/tests/test_databricks_convergence_e2e.py +++ b/tests/test_databricks_convergence_e2e.py @@ -640,86 +640,3 @@ def test_databricks_integer_target_alias_does_not_create_false_drift() -> None: assert preview.operations[0].kind is NativeOperationKind.NO_OP _assert_in_sync(workflow) - - -def test_cli_round_trip_matches_application_service_and_converges( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - release_context = _release_context( - _property("id", "integer", required=True), - ) - workspace = _StatefulWorkspace(present=False) - adapter = _adapter(workspace) - service = DeploymentService() - - expected_plan = service.plan(release_context.release, _target()) - expected_preview = service.preview(expected_plan, adapter=adapter) - authorization = authorize_deployment( - expected_plan, - release_context.release, - release_context.deploy_authorization, - ) - - release_path = tmp_path / "release.json" - plan_path = tmp_path / "plan.json" - preview_path = tmp_path / "preview.json" - authorization_path = tmp_path / "authorization.json" - release_path.write_text( - release_context.release.model_dump_json(), - encoding="utf-8", - ) - - plan_result = deployment_cmd.run_deployment_plan( - argparse.Namespace( - release=str(release_path), - platform="databricks", - runtime=_RUNTIME_TARGET, - source_reference=_SOURCE, - server="production", - ) - ) - cli_plan = DeploymentPlan.model_validate_json(plan_result.output) - assert cli_plan == expected_plan - plan_path.write_text(cli_plan.model_dump_json(), encoding="utf-8") - - import semapact.platforms.runtime_registry as runtime_registry - - monkeypatch.setattr( - runtime_registry, - "create_deployment_adapter", - lambda platform, **kwargs: adapter, - ) - - preview_result = deployment_cmd.run_deployment_preview( - argparse.Namespace(plan=str(plan_path)) - ) - cli_preview = DeploymentPreview.model_validate_json(preview_result.output) - assert cli_preview == expected_preview - preview_path.write_text(cli_preview.model_dump_json(), encoding="utf-8") - authorization_path.write_text( - authorization.model_dump_json(), - encoding="utf-8", - ) - - execute_result = deployment_cmd.run_deployment_execute( - argparse.Namespace( - plan=str(plan_path), - preview=str(preview_path), - authorization=str(authorization_path), - warehouse_id="warehouse-golden", - ) - ) - assert json.loads(execute_result.output)["providerExecution"] == "SUCCEEDED" - - verify_result = deployment_cmd.run_deployment_verify( - argparse.Namespace( - plan=str(plan_path), - output="json", - ) - ) - assert json.loads(verify_result.output)["status"] == "IN_SYNC" - assert workspace.statements == [ - "CREATE TABLE `main`.`silver`.`orders` " - "(`id` INT NOT NULL) USING DELTA" - ] From a94cd5bd8fcbaf721ddd81c2bbfd64f240780f1d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:19:07 +1000 Subject: [PATCH 129/616] test(databricks): cover formal release UC provenance tags --- tests/test_deployment_databricks.py | 44 ++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/test_deployment_databricks.py b/tests/test_deployment_databricks.py index 8e0f5662..cb8aec4a 100644 --- a/tests/test_deployment_databricks.py +++ b/tests/test_deployment_databricks.py @@ -7,6 +7,7 @@ import pytest from open_data_contract_standard.model import SchemaObject, SchemaProperty +from semapact.deployment import RuntimeReleaseMetadata from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, @@ -310,7 +311,7 @@ def test_execute_rejects_tampered_plan_and_forged_native_command() -> None: adapter, _, client = _adapter(current) preview = adapter.preview(plan) - tampered = plan.model_copy(update={"selected_version": "9.9.9"}) + tampered = plan.model_copy(update={"contract_version": "9.9.9"}) with pytest.raises(ValueError, match="DeploymentPlan deterministic identity"): adapter.execute(tampered, preview, _authorization(plan)) @@ -353,6 +354,47 @@ def test_execute_runs_exact_preview_statement() -> None: ] +def test_release_metadata_projects_version_and_provenance_as_uc_tags() -> None: + plan = _plan(_property("id", "BIGINT", required=True)) + current = _state(("id", "bigint", False)) + adapter, _, client = _adapter(current) + + adapter.project_release_metadata( + plan, + RuntimeReleaseMetadata( + contract_id="orders-product", + contract_version="1.2.0", + contract_release_id="release-record-1", + revision_ref="rev:released", + ), + ) + + assert client.statement_execution.calls == [ + "ALTER TABLE `main`.`silver`.`orders` SET TAGS " + "('semapact_contract_id' = 'orders-product', " + "'semapact_contract_version' = '1.2.0', " + "'semapact_release_id' = 'release-record-1', " + "'semapact_revision' = 'rev:released')" + ] + + +def test_release_metadata_projection_requires_warehouse() -> None: + plan = _plan(_property("id", "BIGINT", required=True)) + current = _state(("id", "bigint", False)) + adapter, _, _ = _adapter(current, warehouse_id=None) + + with pytest.raises(ValidationError, match="warehouse_id"): + adapter.project_release_metadata( + plan, + RuntimeReleaseMetadata( + contract_id="orders-product", + contract_version="1.2.0", + contract_release_id="release-record-1", + revision_ref="rev:released", + ), + ) + + def test_no_op_execute_does_not_require_warehouse() -> None: plan = _plan(_property("id", "BIGINT", required=True)) current = _state(("id", "bigint", False)) From 3b8085c85b8cae7b52f03ce1490d57491ba65ebd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:19:56 +1000 Subject: [PATCH 130/616] docs(deployment): define release mode operational history and UC tags --- docs/deployment_plans.md | 281 +++++++++++++++++++++++++-------------- 1 file changed, 178 insertions(+), 103 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 3a514aa2..32bdece5 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -146,7 +146,32 @@ The plan does not contain credentials, workspace clients, SQL connections, or pr ## CI assessment and the DeploymentBundle boundary -CI must produce a stable artifact that CD can consume after approval. SemaPact packages the exact planning material into a content-addressed `DeploymentBundle`: +Deployment and formal contract release are related but distinct lifecycles. + +By default, `deployment assess` prepares a **candidate deployment**. It evaluates governance and freezes the exact candidate revision, but it does not calculate a new semantic version, create release approval evidence, or create release history: + +```text +base + candidate + ↓ +GovernanceDecision +→ ChangeSet +→ DeploymentSourceSnapshot(release=false) +→ DeploymentPlan + + +fresh runtime observation + ↓ +DeploymentPreview # review evidence only + ↓ +DeploymentBundle(release=false) +``` + +A formal release is explicit: + +```bash +semapact deployment assess ... --release +``` + +Only that mode enters ContractOps release planning: ```text base + candidate @@ -156,28 +181,34 @@ GovernanceDecision → ReleasePlan → VersionResolution → ReleaseSnapshot +→ DeploymentSourceSnapshot(release=true) → DeploymentPlan + fresh runtime observation ↓ -DeploymentPreview # review evidence only - ↓ -DeploymentBundle +DeploymentPreview ↓ -sha256 content digest +DeploymentBundle(release=true) ``` -`DeploymentBundle` is a transport and integrity boundary, not another governance authority. It contains the canonical decision/planning artifacts, the exact release snapshot, the target-specific deployment plan, and the runtime preview that CI showed to reviewers. +The `release` flag is content-addressed inside the bundle. CD cannot reinterpret the same CI artifact as release or non-release later. -The bundle contains **no execution authorization**. The CI preview is also not a future SQL script: CD must re-observe runtime and re-derive provider operations at the mutation boundary. The bundle digest lets CI publish one immutable artifact and lets approval/CD pin the exact reviewed material. +A contract release is environment-neutral. One released version can subsequently be deployed to multiple targets without another version bump: -The previous candidate-specific `DeploymentAssessment` artifact is intentionally not part of this model. CI uses the same canonical `DeploymentPlan → preview` path as later deployment rather than maintaining a second desired-vs-runtime workflow. +```text +orders@1.4.0 +├── dev +├── test +└── prod +``` + +Promotion between targets is deployment lifecycle, not contract version lifecycle. ## CLI workflow -The deployment CLI exposes one bundle workflow: `assess` for CI/manual planning and `deploy` for CD/manual execution. `approve` remains an optional hook for custom/manual workflows that want to supply an explicit ApprovalRecord. +The public deployment workflow remains `assess → deploy`. `approve` is an optional explicit hook for custom/manual release workflows. -### Assess +### Candidate deployment — default ```bash semapact deployment assess \ @@ -186,170 +217,214 @@ semapact deployment assess \ --base-revision-ref git:abc123 \ --candidate-revision-ref git:def456 \ --effective-date 2026-09-20 \ - --server production \ - --bundle-out ./artifacts/orders-prod.bundle.json \ - --output text + --server development \ + --bundle-out ./artifacts/orders-dev.bundle.json ``` -When the candidate contract defines the selected server, its platform, catalog/schema target, and host are authoritative. For contracts without servers, provide `--platform`, `--runtime`, and `--source-reference`. +This keeps the candidate contract's existing version unchanged. It does not create `ReleasePlan`, `VersionResolution`, `ReleaseSnapshot`, `ApprovalRecord`, or `ContractReleaseRecord`. -The bundle file contains the canonical planning artifacts, `ReleaseSnapshot`, v3 `DeploymentPlan`, and CI-time `reviewPreview`, protected by `bundleDigest`. Use `--output json` to emit that same bundle on stdout instead. CI can publish the file using its normal pipeline-artifact mechanism; SemaPact does not couple this package to one CI vendor. +A non-release `GovernanceDecision(REVIEW)` may still be deployed for validation/test environments without release approval. `BLOCK` always fails closed. -### CI artifact handoff +### Formal release + +Add `--release` only when this workflow is intended to create a new formal contract version: + +```bash +semapact deployment assess \ + --base ./contracts/orders.yaml \ + --candidate ./contracts/orders.candidate.yaml \ + --base-revision-ref git:abc123 \ + --candidate-revision-ref git:def456 \ + --effective-date 2026-09-20 \ + --server production \ + --release \ + --bundle-out ./artifacts/orders-prod.bundle.json +``` + +Release mode calculates the semantic version exactly once and embeds the resulting release artifacts in the immutable bundle. A REVIEW release requires exact approval evidence. An ALLOW release does not require human approval. -SemaPact deliberately emits an ordinary file artifact so CI systems can use their native immutable artifact store. The pipeline should publish the exact bundle file produced by `assess`; CD should download that artifact rather than rebuilding it from the merged repository state. +### CI artifact handoff -Generic CI: +Both modes publish the exact bundle produced in CI. CD must download that artifact rather than rebuilding it from merged repository state. ```text -CI job - semapact deployment assess --bundle-out deployment.bundle.json +CI + semapact deployment assess [...] ↓ - publish deployment.bundle.json + immutable DeploymentBundle ↓ - record immutable artifact/digest reference + pipeline artifact storage ↓ -approval +CD + download exact bundle ↓ -CD job - download the exact published deployment.bundle.json - ↓ - semapact deployment deploy --bundle deployment.bundle.json -``` - -GitHub Actions can use the normal artifact actions: - -```yaml -- name: Build SemaPact deployment bundle - run: | - semapact deployment assess \ - --base contracts/orders.base.yaml \ - --candidate contracts/orders.yaml \ - --base-revision-ref "git:${{ github.event.pull_request.base.sha }}" \ - --candidate-revision-ref "git:${{ github.event.pull_request.head.sha }}" \ - --effective-date "$(date -u +%F)" \ - --server production \ - --bundle-out artifacts/orders-prod.bundle.json - -- name: Publish deployment bundle - uses: actions/upload-artifact@v4 - with: - name: semapact-orders-prod - path: artifacts/orders-prod.bundle.json - if-no-files-found: error + semapact deployment deploy --bundle ... ``` -The corresponding CD job downloads that exact artifact and passes it unchanged to `deployment deploy`. Azure DevOps should use the equivalent Pipeline Artifact publish/download tasks. SemaPact does not require a vendor-specific artifact registry. - -Do not regenerate the bundle in CD. Rebuilding would create a new CI boundary and could make approval refer to material different from what CD consumes. CD is allowed—and required—to re-observe runtime, but not to replace the approved desired-state bundle. +For a formal release, the bundle also carries the exact version resolution that will become the release fact. For a candidate deployment, no release version is calculated. ### Review evidence intake -The normal CI/CD path does **not** require `semapact deployment approve`. +The normal CI/CD path does **not** require a human to run `semapact deployment approve`. -The review happens in the external workflow provider. A trusted integration records that provider event into SemaPact approval history, for example through the generic `semapact approval record` intake surface. That history record carries the exact DEPLOY context, `deploymentPlanId`, and `bundleDigest` evidence. +For a formal REVIEW release, GitHub / Azure DevOps / GitLab owns the review interaction. A trusted integration records the provider event as an `ApprovalRecord` in Git-backed approval history. CD resolves the exact record automatically when `--approval` is omitted. -```text -GitHub / Azure DevOps / GitLab review - ↓ -trusted workflow integration - ↓ -ApprovalRecord in .semapact/history - ↓ -CD: semapact deployment deploy --bundle ... - ↓ -automatic exact approval resolution -``` +A usable release approval must match the exact: -`semapact deployment approve --bundle ...` remains a convenience for manual/custom workflows that want to construct an explicit approval artifact directly. It is not a required stage in standard CI/CD. +- `decisionId`; +- `changeSetId`; +- `releasePlanId`; +- `versionResolutionId`; +- `DEPLOY` operation; +- `deploymentPlanId`; +- `bundleDigest`. -### Deploy +Conflicting exact review actions fail closed. There is no hidden "latest approval wins" rule. -After CI publishes the exact bundle and any required human approval is recorded, CD consumes that bundle directly: +Custom/manual release workflows can still inject one directly: ```bash semapact deployment deploy \ --bundle ./artifacts/orders-prod.bundle.json \ - --warehouse-id \ - --output json + --approval ./artifacts/orders-prod.approval.json \ + --warehouse-id ``` -For `GovernanceDecision(REVIEW)`, `deploy` first looks for an exact matching DEPLOY approval in Git-backed history under `.semapact/history` (or `--repository-root`). The record must match the exact decision/change-set/release/version context, exact `deploymentPlanId`, and exact `bundleDigest`. Conflicting exact review actions fail closed. +Candidate deployments do not read or write release approval records. -Custom workflows may inject an approval artifact explicitly: +### Deploy + +Candidate CD: ```bash semapact deployment deploy \ - --bundle ./artifacts/orders-prod.bundle.json \ - --approval ./artifacts/orders-prod.approval.json \ - --warehouse-id + --bundle ./artifacts/orders-dev.bundle.json \ + --warehouse-id ``` -An explicit `--approval` takes precedence over Git-history resolution. For governance-ALLOW deployments, no human approval is required. +Formal release CD uses the same command. The release mode is already pinned inside the bundle. -The CD workflow deliberately ignores the CI-time review preview as execution input: +At execution time SemaPact always re-observes runtime: ```text -load + validate exact DeploymentBundle -→ validate approval / operation authorization +exact bundle +→ authorize exact deployment source → fresh runtime observation → fresh DeploymentPreview -→ execute exact fresh preview -→ fresh verification +→ execute +→ fresh reconciliation → IN_SYNC / DRIFT / INDETERMINATE ``` -If runtime changed between CI and CD, the fresh preview may differ. A CI-time ALTER can become NO_OP; a newly unsafe or conflicting transition fails closed. Provider execution success is not sufficient: the command returns reconciliation semantics after a separate fresh verify. +The CI preview is review evidence only. It is never replayed as executable SQL. + +## Governance ledger vs operational history + +SemaPact deliberately separates low-frequency governance facts from high-frequency deployment telemetry. + +### Git governance ledger + +Git-backed history is appropriate for durable reviewable facts such as: + +```text +ApprovalRecord +ContractReleaseRecord +``` + +A formal `--release` deployment creates one immutable `ContractReleaseRecord` containing the released contract version, exact revision, release artifacts, and bundle digest. A candidate deployment creates no release history. + +The Git adapter writes deterministic history files under `.semapact/history/`. The surrounding GitOps workflow remains responsible for committing/publishing those files; SemaPact does not silently push repository branches. + +The formal release fact is independent of runtime convergence. Once `ContractReleaseRecord` is created, a later runtime deployment failure does not undo or renumber the release. -For governance-ALLOW deployments, `--approval` may be omitted. +### Operational history — opt in + +Deployment executions can be much more frequent than releases, so SemaPact does **not** write deployment/reconciliation telemetry to Git by default. + +Without configuration: + +```text +deployment execute + verify +→ return DeploymentExecutionResult +→ no operational history persistence +``` + +To persist operational telemetry explicitly: + +```bash +# local / lightweight +semapact deployment deploy \ + --bundle ./artifacts/orders-dev.bundle.json \ + --operational-history sqlite:///./.semapact/operational.db + +# shared / higher-volume +semapact deployment deploy \ + --bundle ./artifacts/orders-prod.bundle.json \ + --operational-history delta:///path/to/semapact_operational_history +``` + +Supported operational history backends in this slice are SQLite and Delta. The Delta backend is lazy and requires the `delta` optional extra. + +Operational events record concrete deployment occurrence facts including success/failure, exact bundle/plan/source provenance, target, timestamps, and reconciliation status when available. ## Databricks deployment capability -The first Databricks write slice is intentionally narrow and fail-closed. +The first Databricks write slice remains intentionally narrow and fail-closed for schema mutation: | Observed state | Supported behavior | | --- | --- | | Governed table is missing | `CREATE TABLE ... USING DELTA` as a managed table | | Existing `MANAGED` table is missing a governed nullable column | `ALTER TABLE ... ADD COLUMNS (...)` | | Existing `MANAGED` table already satisfies the governed shape | `NO_OP` | -| Runtime contains extra columns not governed by this contract | Leave them untouched; no inferred `DROP` | +| Runtime contains extra columns not governed by this contract | Leave them untouched | -For this slice, `ALTER` means **only additive nullable-column change**. The adapter does not interpret `ALTER` as generic schema evolution. +Rename, existing-column type/nullability mutation, unsafe required-column addition, DROP, non-managed mutation, and ambiguous mappings fail closed. -The following are rejected rather than guessed or silently converted: +### Unity Catalog release provenance tags -- column rename; -- existing-column physical type change; -- existing-column nullability change; -- adding a required/non-null column without an explicit safe migration/default strategy; -- `DROP` or other destructive reconciliation; -- mutation of existing external/non-managed assets; -- unsupported or ambiguous provider mappings. +After a **formal release** reaches schema reconciliation status `IN_SYNC`, the Databricks adapter projects SemaPact-owned release provenance onto every governed Unity Catalog table: -Existing external/non-managed assets remain observable through the runtime read side, but this deployment adapter does not claim mutation authority over them. +```text +semapact_contract_id +semapact_contract_version +semapact_release_id +semapact_revision +``` -The adapter is also not a general Databricks infrastructure engine. Workspace, catalog, schema, SQL warehouse, credentials, external locations, storage configuration, grants, jobs, and clusters are outside this deployment boundary and must be provisioned separately. +For example, a released `orders@1.4.0` table is tagged with version `1.4.0` and the exact `ContractReleaseRecord` identity. Candidate/non-release deployments never publish formal release/version tags. -## Authorization scope +This projection is deliberately limited to SemaPact provenance. ODCS business tags, classifications, PII labels, or governed ABAC tags are **not** automatically mapped to Unity Catalog tags; those require a separate explicit mapping policy. + +Tag mutation is a provider side effect and therefore requires a Databricks SQL warehouse even when the schema deployment itself resolves to `NO_OP`. A tag-write failure fails the release deployment command rather than silently claiming the runtime is fully projected. -Runtime deployment is a separate protected operation from publishing a contract release artifact. +## Authorization scope -At the canonical application boundary, REVIEW deployments require exact approval evidence bound to the `deploymentPlanId` and `bundleDigest`. The CLI can resolve that evidence from Git-backed approval history automatically, or callers can provide an explicit `ApprovalRecord` for custom workflows. This makes the CI artifact itself part of the approval scope rather than approving a mutable path or SQL string. +Candidate and release deployment use different authorization semantics: -The current low-level domain implementation still bridges this into the historical `ContractOpsAuthorization → DeploymentAuthorization` types before calling the adapter. That bridge is compatibility machinery; Data Engineers and CI/CD callers do not construct those artifacts in the bundle workflow. A future cleanup can collapse the bridge without changing the public bundle contract. +```text +candidate deployment +→ exact GovernanceDecision + candidate source snapshot +→ BLOCK fails closed +→ no release ApprovalRecord + +formal release deployment +→ exact ContractOps release context +→ REVIEW requires exact approval +→ exact DeploymentPlan authorization +``` -A PUBLISH authorization cannot authorize DEPLOY. +In both modes, authorization binds the exact source snapshot and target-specific DeploymentPlan before runtime mutation. ## Determinism `deploymentPlanId` is UUID5-derived from the full stable plan record: -- exact release identity and provenance (`ReleaseSnapshot` for v3 plans; legacy applied-release identity for v2 compatibility); +- exact deployment source snapshot (candidate or release) and revision/version provenance; - exact deployment target, including runtime source reference; - canonical actions ordered by governed asset identity; - plan schema version. -The same exact release and target therefore produce the same DeploymentPlan. +The same exact deployment source and target therefore produce the same DeploymentPlan. Action ordering is canonical even when schemas appear in a different order in source ODCS. However, DeploymentPlan does not redefine release identity: two distinct exact release artifacts remain distinct plan inputs even if their projected actions happen to be equivalent. From a230c95ebd2be89df0f1aca15c1d12e159bcfb5b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:20:26 +1000 Subject: [PATCH 131/616] docs(readme): separate candidate deployment from formal release --- README.md | 77 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3d38072c..8498e4a5 100644 --- a/README.md +++ b/README.md @@ -179,30 +179,57 @@ See [`docs/contractops_phases.md`](docs/contractops_phases.md) and [`docs/versio ### Governed runtime deployment -`semapact deployment` exposes one bundle-driven CLI/CI workflow: +SemaPact separates **candidate deployment** from **formal contract release**. -```text -assess -→ governance + ReleaseSnapshot + DeploymentPlan + fresh review preview -→ content-addressed DeploymentBundle -→ read-only / no execution authority +The default path deploys an exact candidate revision without creating another semantic version: -approve -→ optional explicit ApprovalRecord hook for custom/manual workflows +```text +deployment assess +→ GovernanceDecision + ChangeSet +→ exact candidate DeploymentSourceSnapshot +→ DeploymentPlan + fresh CI preview +→ DeploymentBundle(release=false) -deploy -→ exact DeploymentBundle -→ resolve Git-backed approval history for REVIEW, unless explicit approval is supplied +deployment deploy → fresh preview → execute → fresh verify → IN_SYNC / DRIFT / INDETERMINATE ``` -`assess` is the CI/manual planning boundary and `deploy` is the CD/manual execution boundary. `approve` is optional: standard GitOps/CD flows can record provider approval events into Git-backed approval history and let `deploy` resolve the exact matching record automatically. Internal planning, preview, execution, and verification primitives remain application/domain APIs rather than separate Data Engineer CLI phases. +A formal release is explicit: + +```bash +semapact deployment assess ... --release +``` -The first Databricks write capability is intentionally narrow: create a missing managed Delta table, add missing nullable governed columns to an existing managed table, or perform NO_OP when the governed shape is already satisfied. Rename, existing-column type/nullability mutation, required-column addition without a safe migration strategy, DROP, and existing external/non-managed asset mutation fail closed. +Release mode adds: -See [`docs/deployment_plans.md`](docs/deployment_plans.md). +```text +ReleasePlan +→ VersionResolution +→ ReleaseSnapshot +→ DeploymentBundle(release=true) +→ exact approval when REVIEW +→ ContractReleaseRecord +``` + +A released version is environment-neutral. The same version can later be deployed to dev, test, and prod without another version bump. + +`deploy` always re-observes runtime before execution; CI-time preview operations are never replayed blindly. + +Deployment telemetry is disabled by default. High-frequency execution history can be enabled explicitly with a SQLite or Delta operational-history backend; it is not written into Git governance history. + +For Databricks, once a **formal release** is verified `IN_SYNC`, SemaPact projects release provenance to governed Unity Catalog tables using reserved tags: + +```text +semapact_contract_id +semapact_contract_version +semapact_release_id +semapact_revision +``` +Candidate deployments do not publish formal version/release tags. Business classifications or ABAC tags are not automatically mapped. + +See [`docs/deployment_plans.md`](docs/deployment_plans.md). ### Databricks discovery and observation With the `databricks` extra, SemaPact provides a thin read-side integration using the official Databricks SDK: @@ -328,7 +355,20 @@ pip install "semapact[databricks]" The Databricks SDK owns authentication-provider selection. SemaPact forwards supported connection hints rather than implementing a separate credential system. -Before approval, assess the candidate contract against the selected Databricks target without creating execution authority: +Assess a candidate deployment without creating a new contract version: + +```bash +semapact deployment assess \ + --base ./contracts/orders.yaml \ + --candidate ./contracts/orders.candidate.yaml \ + --base-revision-ref git:abc123 \ + --candidate-revision-ref git:def456 \ + --effective-date 2026-09-20 \ + --server development \ + --bundle-out ./artifacts/orders-dev.bundle.json +``` + +Use `--release` only when that CI artifact is intended to create a new formal contract release/version: ```bash semapact deployment assess \ @@ -338,12 +378,11 @@ semapact deployment assess \ --candidate-revision-ref git:def456 \ --effective-date 2026-09-20 \ --server production \ + --release \ --bundle-out ./artifacts/orders-prod.bundle.json ``` -The bundle contains the exact reviewed `ReleaseSnapshot`, v3 `DeploymentPlan`, CI-time review preview, and a SHA-256 content digest. CI publishes this file using its normal artifact mechanism; the preview remains review evidence only and must be refreshed at CD execution time. - -CD then consumes that exact artifact: +CD consumes the exact bundle in either mode: ```bash semapact deployment deploy \ @@ -351,7 +390,7 @@ semapact deployment deploy \ --warehouse-id ``` -For REVIEW decisions, the command resolves an exact matching DEPLOY approval from Git-backed `.semapact/history` by default. Custom workflows may pass `--approval ./approval.json` explicitly instead. The approval evidence must bind the exact deployment plan and bundle digest. The deploy command re-observes runtime, derives a fresh preview, executes only that fresh plan, then performs a separate fresh convergence verification. +Formal REVIEW releases resolve exact approval evidence from Git-backed history unless `--approval` is supplied explicitly. Candidate deployments do not require release approval. Operational deployment history is optional via `--operational-history sqlite:///...` or `delta:///...`. ## Optional Dependencies From 9b7883f3ba3892248c6cde80fd9aa45228905d27 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:20:42 +1000 Subject: [PATCH 132/616] docs(contractops): separate formal release from runtime deployment --- docs/contractops_phases.md | 47 +++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/docs/contractops_phases.md b/docs/contractops_phases.md index 5f1606ce..95a5e957 100644 --- a/docs/contractops_phases.md +++ b/docs/contractops_phases.md @@ -4,30 +4,39 @@ ContractOps separates reasoning from side effects so CI, agents, APIs, and user ## Canonical contract release flow +Contract release is explicit and distinct from ordinary runtime deployment. + +Candidate deployment does not enter the version-resolution path: + ```text ANALYZE → GovernanceDecision +→ ChangeSet +→ candidate DeploymentSourceSnapshot +→ DeploymentPlan +→ runtime deployment +``` + +A formal release enters ContractOps release planning exactly once: -PLAN +```text +ANALYZE +→ GovernanceDecision + +PLAN RELEASE → ChangeSet → ReleasePlan → VersionResolution → ReleaseSnapshot -CI DEPLOYMENT PLANNING -→ DeploymentPlan -→ DeploymentPreview -→ DeploymentBundle - -AUTHORIZE -→ operation-scoped authorization +FORMAL RELEASE +→ immutable ContractReleaseRecord -PUBLISH / DEPLOY -→ external side effects +DEPLOY +→ one or more target-specific DeploymentPlan / deployment occurrences ``` -Each phase consumes artifacts from the previous phases. Later phases do not recalculate earlier decisions. - +The same formal contract version may be deployed to dev, test, and prod without another version bump. Runtime promotion is not a new contract release. ## The contract is the desired-state artifact SemaPact does not introduce a canonical BUILD phase that turns a contract into a separately authoritative DDL artifact. @@ -156,7 +165,7 @@ Release snapshot construction is pure. It materializes the exact selected ODCS s - does not rerun governance or version authority; - produces a deterministic `ReleaseSnapshot` with no authorization ID. -This pure snapshot is what CI uses to build a v3 `DeploymentPlan` and content-addressed `DeploymentBundle` before human approval. +This pure snapshot is used only for formal `--release` bundles. Candidate deployment freezes the candidate directly in a non-release `DeploymentSourceSnapshot` and does not calculate a new semantic version. ## APPLY compatibility @@ -186,16 +195,18 @@ Git, storage, and other release-artifact publication behavior belongs in adapter ## DEPLOY -DEPLOY mutates a runtime toward the exact `DeploymentPlan` packaged by CI and is separate from release publication. +DEPLOY mutates a runtime toward one exact target-specific `DeploymentPlan`. It is downstream of governance and may consume either: -The canonical application workflow consumes the immutable `DeploymentBundle` directly. For REVIEW decisions, the supplied `ApprovalRecord` must be scoped to the exact `deploymentPlanId` and reference the exact `bundleDigest`. The workflow then re-observes runtime, derives a fresh preview, executes, and performs a separate fresh convergence verification. +- a candidate deployment source (`release=false`), with no release version/approval/history; or +- a formal release source (`release=true`), with exact release/version provenance and REVIEW approval when required. -The current domain layer still uses `ContractOpsAuthorization(operation=DEPLOY)` and `DeploymentAuthorization` internally as a compatibility bridge. Those types are not part of the new Data Engineer / CI-CD happy path and callers do not need to assemble them manually. +Candidate REVIEW deployments are allowed as non-release validation/test deployments without creating release approval evidence. `BLOCK` always fails closed. -Platform-specific execution belongs behind a deployment adapter. The adapter must not recompute governance, version authority, release planning, or approval semantics. +For formal release mode, the new `ContractReleaseRecord` is the durable Git governance fact. Runtime deployment history is operational telemetry and is not written to Git by default. -See [`deployment_plans.md`](deployment_plans.md) for the deployment CLI, Databricks capability boundary, preview integrity checks, and convergence verification semantics. +Provider-specific execution stays behind the deployment adapter. For Databricks, a formal release that reaches `IN_SYNC` also projects SemaPact-owned release/version provenance into Unity Catalog tags. +See [`deployment_plans.md`](deployment_plans.md) for the complete candidate/release CLI flow, operational-history backends, Databricks tags, and convergence semantics. ## Failure semantics ContractOps distinguishes invalid context from denied authorization: From 0908d3c613d5ae772eacc9ceebcbd3dddea4674a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:20:52 +1000 Subject: [PATCH 133/616] docs(approval): scope deployment approval to formal releases --- docs/approval_history.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/approval_history.md b/docs/approval_history.md index 332e2a09..e2204c43 100644 --- a/docs/approval_history.md +++ b/docs/approval_history.md @@ -104,9 +104,9 @@ Callers using another future persistence backend can provide the same ## Deployment approval resolution -For bundle-driven deployment, callers do not have to pass an approval file explicitly. +For a **formal REVIEW release** (`DeploymentBundle.release=true`), callers do not have to pass an approval file explicitly. Candidate/non-release deployments never create or resolve release approval records. -When `semapact deployment deploy --bundle ...` receives a REVIEW bundle without `--approval`, the CLI queries Git-backed approval history under `.semapact/history/approval_records` and resolves only records that match all of the following: +When `semapact deployment deploy --bundle ...` receives a formal REVIEW release bundle without `--approval`, the CLI queries Git-backed approval history under `.semapact/history/approval_records` and resolves only records that match all of the following: - exact `decisionId`; - exact `changeSetId`; @@ -120,7 +120,7 @@ A matching `APPROVE` record can therefore be recorded by a trusted GitHub/Azure If exact scoped history contains conflicting review actions such as `REQUEST_CHANGES` or `REJECT`, resolution fails closed. SemaPact does not apply a hidden "latest review wins" rule. -Passing `--approval ` remains supported for custom/manual workflows and takes precedence over Git-history lookup. +Passing `--approval ` remains supported for custom/manual formal-release workflows and takes precedence over Git-history lookup. Supplying an approval to a non-release deployment is rejected. ## Trust boundary From 84fd3412167bbdd4ab3f82eb1660a55964f5e46f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:22:49 +1000 Subject: [PATCH 134/616] fix(deployment): preserve legacy authorization artifacts --- semapact/deployment/authorization.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index c32b9d3b..a64bd9a9 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -67,6 +67,22 @@ def authorize_deployment( ) source_snapshot_id = plan.source_snapshot_id + if plan.plan_version in {"2", "3"}: + deployment_authorization_id = compute_deployment_authorization_id( + contract_ops_authorization_id=authorization.authorization_id, + deployment_plan_id=plan.deployment_plan_id, + applied_release_id=source_snapshot_id, + allowed=authorization.allowed, + authorization_version="1", + ) + return DeploymentAuthorization( + deployment_authorization_id=deployment_authorization_id, + contract_ops_authorization_id=authorization.authorization_id, + deployment_plan_id=plan.deployment_plan_id, + applied_release_id=source_snapshot_id, + allowed=authorization.allowed, + ) + deployment_authorization_id = compute_deployment_authorization_id( deployment_plan_id=plan.deployment_plan_id, source_snapshot_id=source_snapshot_id, From 407ab698a77efe5e44dd2277024b82a6d7f288c6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:22:56 +1000 Subject: [PATCH 135/616] test(deployment): tamper canonical v4 contract version field --- tests/test_deployment_verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_deployment_verification.py b/tests/test_deployment_verification.py index c01a2bad..4937a896 100644 --- a/tests/test_deployment_verification.py +++ b/tests/test_deployment_verification.py @@ -194,7 +194,7 @@ def test_runtime_source_mismatch_fails_closed() -> None: def test_tampered_deployment_plan_identity_fails_closed() -> None: - plan = _plan().model_copy(update={"selected_version": "9.9.9"}) + plan = _plan().model_copy(update={"contract_version": "9.9.9"}) with pytest.raises(ValueError, match="deterministic identity"): verify_deployment_convergence(plan, FakeRuntimeProvider(_observation())) From 2a2b682b88c183d5d4693448eca1ce18a0c69ece Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:23:17 +1000 Subject: [PATCH 136/616] test(history): record configured failed deployment occurrences --- tests/test_deployment_workflow_service.py | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 2f46c1ab..dcfaae8f 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -146,6 +146,19 @@ def verify(self, plan) -> ReconciliationResult: ) +class _OperationalSink: + def __init__(self) -> None: + self.events = [] + + def record_deployment(self, event) -> None: + self.events.append(event) + + +class _FailingExecutionAdapter(_ExecutionAdapter): + def execute(self, plan, preview, authorization) -> None: + raise RuntimeError("provider mutation failed") + + class _MetadataProjector: def __init__(self) -> None: self.calls = [] @@ -354,6 +367,35 @@ def test_cd_uses_fresh_preview_not_ci_review_preview() -> None: assert result.review_preview_changed is True +def test_configured_operational_history_records_failed_candidate_deployment() -> None: + service = DeploymentWorkflowService() + bundle = service.assess( + _contract(name="Orders old"), + _contract(name="Orders new"), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=_target(), + adapter=_PreviewAdapter(), + ) + sink = _OperationalSink() + + with pytest.raises(RuntimeError, match="provider mutation failed"): + service.deploy( + bundle, + adapter=_FailingExecutionAdapter(), + operational_history=sink, + ) + + assert len(sink.events) == 1 + event = sink.events[0] + assert event.status == "FAILED" + assert event.release is False + assert event.release_record_id is None + assert event.deployment_preview_id is not None + assert event.reconciliation_status is None + + def test_non_release_bundle_rejects_manual_approval() -> None: service = DeploymentWorkflowService() bundle = service.assess( From 976e49949447dc9b5418ec550a94690c2fb71786 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:24:10 +1000 Subject: [PATCH 137/616] docs(readme): show candidate and release workflow branches --- README.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8498e4a5..0cad2c7d 100644 --- a/README.md +++ b/README.md @@ -67,20 +67,27 @@ GovernanceDecision Governance Gate ``` -Canonical ContractOps then carries the exact governed decision forward: +The exact governed decision then feeds either candidate deployment or an explicit formal release: ```text GovernanceDecision → ChangeSet -→ ReleasePlan -→ VersionResolution -→ ReleaseSnapshot -→ DeploymentPlan -→ DeploymentBundle (CI artifact) - -Approval / authorization remains a separate side-effect boundary before runtime mutation. + ├─ candidate deployment + │ → DeploymentSourceSnapshot + │ → DeploymentPlan + │ → DeploymentBundle + │ + └─ formal release (--release) + → ReleasePlan + → VersionResolution + → ReleaseSnapshot + → DeploymentPlan + → DeploymentBundle + → ContractReleaseRecord ``` +Release approval and runtime authorization remain explicit side-effect boundaries. + For production assurance: ```text From f6207dbd0aa4ba87e000f46642791406a934c871 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:25:13 +1000 Subject: [PATCH 138/616] refactor(history): keep contract release identity target-neutral --- semapact/history/models.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/semapact/history/models.py b/semapact/history/models.py index 564b69a9..594972d1 100644 --- a/semapact/history/models.py +++ b/semapact/history/models.py @@ -152,9 +152,7 @@ class ContractReleaseRecord(HistoryModel): version_resolution_id: str release_snapshot_id: str revision_ref: str - bundle_digest: str released_contract_json: str - approval_id: str | None = None @field_validator( "contract_release_id", @@ -166,21 +164,12 @@ class ContractReleaseRecord(HistoryModel): "version_resolution_id", "release_snapshot_id", "revision_ref", - "bundle_digest", "released_contract_json", ) @classmethod def _require_contract_release_text(cls, value: str) -> str: return _required_text(value) - @field_validator("approval_id") - @classmethod - def _normalize_contract_release_approval( - cls, - value: str | None, - ) -> str | None: - return _optional_text(value) - @model_validator(mode="after") def _validate_contract_release_snapshot(self) -> "ContractReleaseRecord": from open_data_contract_standard.model import OpenDataContractStandard From bccf10016d8c7e5ff7e1d200999887c43f102118 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:25:23 +1000 Subject: [PATCH 139/616] refactor(history): derive release identity from release facts only --- semapact/history/integrity.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/semapact/history/integrity.py b/semapact/history/integrity.py index fd20dc0a..8c94654c 100644 --- a/semapact/history/integrity.py +++ b/semapact/history/integrity.py @@ -111,9 +111,7 @@ def compute_contract_release_record_id( version_resolution_id: str, release_snapshot_id: str, revision_ref: str, - bundle_digest: str, released_contract_json: str, - approval_id: str | None, ) -> str: return deterministic_uuid5( SEMAPACT_CONTRACT_RELEASE_RECORD_NAMESPACE, @@ -126,9 +124,7 @@ def compute_contract_release_record_id( "version_resolution_id": version_resolution_id, "release_snapshot_id": release_snapshot_id, "revision_ref": revision_ref, - "bundle_digest": bundle_digest, "released_contract_json": released_contract_json, - "approval_id": approval_id, }, ) @@ -145,9 +141,7 @@ def validate_contract_release_record_identity( version_resolution_id=record.version_resolution_id, release_snapshot_id=record.release_snapshot_id, revision_ref=record.revision_ref, - bundle_digest=record.bundle_digest, released_contract_json=record.released_contract_json, - approval_id=record.approval_id, ) if record.contract_release_id != expected: raise ValueError( From 0091d3eb0ed42ba67fa94d3399662ca6fa0543ae Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:25:31 +1000 Subject: [PATCH 140/616] refactor(history): keep approval and bundle evidence outside release record --- semapact/application/services/contract_release_history.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/semapact/application/services/contract_release_history.py b/semapact/application/services/contract_release_history.py index f937af0f..bedc882d 100644 --- a/semapact/application/services/contract_release_history.py +++ b/semapact/application/services/contract_release_history.py @@ -48,7 +48,6 @@ def record_release( snapshot = bundle.release_snapshot resolution = bundle.version_resolution - approval_id = approval.approval_id if approval is not None else None record_id = compute_contract_release_record_id( contract_id=snapshot.contract_id, contract_version=snapshot.selected_version, @@ -58,9 +57,7 @@ def record_release( version_resolution_id=resolution.version_resolution_id, release_snapshot_id=snapshot.release_snapshot_id, revision_ref=snapshot.release_revision_ref, - bundle_digest=bundle.bundle_digest, released_contract_json=snapshot.released_contract_json, - approval_id=approval_id, ) record = ContractReleaseRecord( contract_release_id=record_id, @@ -72,9 +69,7 @@ def record_release( version_resolution_id=resolution.version_resolution_id, release_snapshot_id=snapshot.release_snapshot_id, revision_ref=snapshot.release_revision_ref, - bundle_digest=bundle.bundle_digest, released_contract_json=snapshot.released_contract_json, - approval_id=approval_id, ) self._releases.put_contract_release(record) return record From a33c73d5fb33d84f305d408c429965ac75641725 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:25:38 +1000 Subject: [PATCH 141/616] test(history): assert target-neutral contract release facts --- tests/test_deployment_workflow_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index dcfaae8f..3e677599 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -459,8 +459,8 @@ def test_review_release_requires_approval_and_records_formal_release(tmp_path) - repository = GitWorkingTreeHistoryRepository(tmp_path) record = repository.get_contract_release(result.release_record_id) assert record.contract_version == bundle.version_resolution.selected_version - assert record.bundle_digest == bundle.bundle_digest - assert record.approval_id == approval.approval_id + assert record.release_snapshot_id == bundle.release_snapshot.release_snapshot_id + assert record.revision_ref == bundle.release_snapshot.release_revision_ref def test_release_projects_metadata_only_after_in_sync(tmp_path) -> None: From 123e839ed6ff364584f8dd97d1bb7b73eecae854 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:25:51 +1000 Subject: [PATCH 142/616] docs(history): keep formal release record target-neutral --- docs/deployment_plans.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 32bdece5..2065e039 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -331,7 +331,7 @@ ApprovalRecord ContractReleaseRecord ``` -A formal `--release` deployment creates one immutable `ContractReleaseRecord` containing the released contract version, exact revision, release artifacts, and bundle digest. A candidate deployment creates no release history. +A formal `--release` deployment creates one immutable, target-neutral `ContractReleaseRecord` containing the released contract version, exact revision, and release-artifact identities. Target-specific bundle/approval evidence remains separate. A candidate deployment creates no release history. The Git adapter writes deterministic history files under `.semapact/history/`. The surrounding GitOps workflow remains responsible for committing/publishing those files; SemaPact does not silently push repository branches. From c126dbef56b2a6670251f2e76114d05c8e49b27f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:26:16 +1000 Subject: [PATCH 143/616] test(history): keep contract release identity target-neutral --- tests/test_deployment_workflow_service.py | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 3e677599..11fc7017 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -463,6 +463,61 @@ def test_review_release_requires_approval_and_records_formal_release(tmp_path) - assert record.revision_ref == bundle.release_snapshot.release_revision_ref +def test_contract_release_identity_is_target_neutral(tmp_path) -> None: + service = DeploymentWorkflowService() + base = _contract(name="Orders") + candidate = _contract(name="Orders", include_created_at=True) + + dev = service.assess( + base, + candidate, + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=DeploymentTarget( + platform="fake", + runtime_target="main.dev", + source_reference="runtime:dev", + ), + adapter=_PreviewAdapter(), + release=True, + ) + prod = service.assess( + base, + candidate, + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=DeploymentTarget( + platform="fake", + runtime_target="main.prod", + source_reference="runtime:prod", + ), + adapter=_PreviewAdapter(), + release=True, + ) + assert dev.bundle_digest != prod.bundle_digest + assert dev.deployment_plan.deployment_plan_id != prod.deployment_plan.deployment_plan_id + + history = _release_history(tmp_path) + dev_approval = service.approve( + dev, + actor_reference="human:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + ) + prod_approval = service.approve( + prod, + actor_reference="human:reviewer", + recorded_at=datetime(2026, 9, 20, 2, 1, tzinfo=timezone.utc), + ) + + first = history.record_release(dev, approval=dev_approval) + second = history.record_release(prod, approval=prod_approval) + + assert first == second + assert first.contract_release_id == second.contract_release_id + + def test_release_projects_metadata_only_after_in_sync(tmp_path) -> None: service = DeploymentWorkflowService() bundle = _review_release_bundle() From 93654439c452992287cbb2c936fc7061cea63c34 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:27:34 +1000 Subject: [PATCH 144/616] docs(history): clarify legacy Git operational history compatibility --- docs/deployment_plans.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 2065e039..501509cf 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -365,6 +365,8 @@ semapact deployment deploy \ Supported operational history backends in this slice are SQLite and Delta. The Delta backend is lazy and requires the `delta` optional extra. +Legacy Git-backed deployment/runtime history artifacts remain readable for compatibility, but the canonical bundle workflow does not create new operational records in Git. + Operational events record concrete deployment occurrence facts including success/failure, exact bundle/plan/source provenance, target, timestamps, and reconciliation status when available. ## Databricks deployment capability From f734196f24721935a5e677cd00644ffe8bb03aec Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:48:22 +1000 Subject: [PATCH 145/616] docs(cli): clarify warehouse requirement for release tags --- semapact/interfaces/cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index 49de485c..2b703822 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -546,7 +546,10 @@ def _build_parser() -> argparse.ArgumentParser: ) deployment_deploy_parser.add_argument( "--warehouse-id", - help="Databricks SQL warehouse required when fresh operations mutate runtime", + help=( + "Databricks SQL warehouse required for runtime mutation and formal-release " + "Unity Catalog provenance tag projection" + ), ) deployment_deploy_parser.add_argument( "--operational-history", From f70824a0c3a1dbfd758f92407905a395a5b6a723 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:50:21 +1000 Subject: [PATCH 146/616] refactor(history): name contract release identity explicitly --- semapact/application/models/deployment_workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py index 89076e62..ba8ae860 100644 --- a/semapact/application/models/deployment_workflow.py +++ b/semapact/application/models/deployment_workflow.py @@ -31,7 +31,7 @@ class DeploymentExecutionResult(BaseModel): bundle_digest: str deployment_plan_id: str authorization_id: str - release_record_id: str | None = None + contract_release_id: str | None = None fresh_preview: DeploymentPreview reconciliation: ReconciliationResult status: RuntimeDriftStatus From e98bffcd43e1f4b0cbb6d8f3cbf8b9b179d25b60 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:50:24 +1000 Subject: [PATCH 147/616] refactor(history): use contract release identity in operational events --- semapact/history/operational.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/semapact/history/operational.py b/semapact/history/operational.py index 8e265b33..809d3459 100644 --- a/semapact/history/operational.py +++ b/semapact/history/operational.py @@ -30,7 +30,7 @@ class OperationalDeploymentEvent(BaseModel): event_id: str bundle_digest: str release: bool - release_record_id: str | None = None + contract_release_id: str | None = None contract_id: str contract_version: str revision_ref: str @@ -65,7 +65,7 @@ def _require_text(cls, value: str) -> str: return cleaned @field_validator( - "release_record_id", + "contract_release_id", "deployment_preview_id", "deployment_authorization_id", "error_message", @@ -100,7 +100,7 @@ def _validate_event(self) -> "OperationalDeploymentEvent": expected = compute_operational_deployment_event_id( bundle_digest=self.bundle_digest, release=self.release, - release_record_id=self.release_record_id, + contract_release_id=self.contract_release_id, contract_id=self.contract_id, contract_version=self.contract_version, revision_ref=self.revision_ref, @@ -137,7 +137,7 @@ def build_operational_deployment_event( *, bundle_digest: str, release: bool, - release_record_id: str | None, + contract_release_id: str | None, contract_id: str, contract_version: str, revision_ref: str, @@ -157,7 +157,7 @@ def build_operational_deployment_event( event_id = compute_operational_deployment_event_id( bundle_digest=bundle_digest, release=release, - release_record_id=release_record_id, + contract_release_id=contract_release_id, contract_id=contract_id, contract_version=contract_version, revision_ref=revision_ref, @@ -179,7 +179,7 @@ def build_operational_deployment_event( event_id=event_id, bundle_digest=bundle_digest, release=release, - release_record_id=release_record_id, + contract_release_id=contract_release_id, contract_id=contract_id, contract_version=contract_version, revision_ref=revision_ref, From f1cba7a4bbcdf5ca7b64ac7afa53960dd4993c55 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:50:27 +1000 Subject: [PATCH 148/616] refactor(deployment): expose contract release identity consistently --- semapact/application/services/deployment_workflow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index 78757918..4afe4017 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -286,7 +286,7 @@ def deploy( build_operational_deployment_event( bundle_digest=bundle.bundle_digest, release=bundle.release, - release_record_id=( + contract_release_id=( release_record.contract_release_id if release_record is not None else None @@ -322,7 +322,7 @@ def deploy( bundle_digest=bundle.bundle_digest, deployment_plan_id=bundle.deployment_plan.deployment_plan_id, authorization_id=deployment_authorization.deployment_authorization_id, - release_record_id=( + contract_release_id=( release_record.contract_release_id if release_record is not None else None @@ -337,7 +337,7 @@ def deploy( build_operational_deployment_event( bundle_digest=bundle.bundle_digest, release=bundle.release, - release_record_id=result.release_record_id, + contract_release_id=result.contract_release_id, contract_id=bundle.deployment_plan.contract_id, contract_version=bundle.deployment_plan.contract_version, revision_ref=bundle.deployment_plan.revision_ref, From 41da7542723a63db0c6540702f6d51c97f463727 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:50:31 +1000 Subject: [PATCH 149/616] refactor(cli): render contract release identity consistently --- semapact/interfaces/commands/deployment_cmd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 1cea1381..24cd0a7a 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -322,8 +322,8 @@ def _deployment_result_text(result) -> str: f"Deployment plan: {result.deployment_plan_id}", f"Authorization: {result.authorization_id}", *( - [f"Contract release: {result.release_record_id}"] - if result.release_record_id is not None + [f"Contract release: {result.contract_release_id}"] + if result.contract_release_id is not None else [] ), "Provider execution: SUCCEEDED", From 0a92f8bffa98be6c5470744e6bae6c7faf228fee Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:50:38 +1000 Subject: [PATCH 150/616] test(history): use contract release identity in operational events --- tests/test_operational_history.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_operational_history.py b/tests/test_operational_history.py index 5278432d..d2a27bf6 100644 --- a/tests/test_operational_history.py +++ b/tests/test_operational_history.py @@ -17,7 +17,7 @@ def _event(): return build_operational_deployment_event( bundle_digest="sha256:" + ("a" * 64), release=False, - release_record_id=None, + contract_release_id=None, contract_id="orders-product", contract_version="1.2.3", revision_ref="git:abc123", From ae1dabe446eedbbcbbe2938dd947fbe0d86599fe Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:50:41 +1000 Subject: [PATCH 151/616] test(deployment): assert contract release identity explicitly --- tests/test_deployment_workflow_service.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 11fc7017..5f95a23e 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -340,7 +340,7 @@ def test_candidate_review_deployment_needs_no_approval_or_release_history() -> N result = service.deploy(bundle, adapter=_ExecutionAdapter()) - assert result.release_record_id is None + assert result.contract_release_id is None assert result.status is RuntimeDriftStatus.IN_SYNC @@ -391,7 +391,7 @@ def test_configured_operational_history_records_failed_candidate_deployment() -> event = sink.events[0] assert event.status == "FAILED" assert event.release is False - assert event.release_record_id is None + assert event.contract_release_id is None assert event.deployment_preview_id is not None assert event.reconciliation_status is None @@ -455,9 +455,9 @@ def test_review_release_requires_approval_and_records_formal_release(tmp_path) - release_history=_release_history(tmp_path), ) - assert result.release_record_id is not None + assert result.contract_release_id is not None repository = GitWorkingTreeHistoryRepository(tmp_path) - record = repository.get_contract_release(result.release_record_id) + record = repository.get_contract_release(result.contract_release_id) assert record.contract_version == bundle.version_resolution.selected_version assert record.release_snapshot_id == bundle.release_snapshot.release_snapshot_id assert record.revision_ref == bundle.release_snapshot.release_revision_ref @@ -540,7 +540,7 @@ def test_release_projects_metadata_only_after_in_sync(tmp_path) -> None: assert len(projector.calls) == 1 plan, metadata = projector.calls[0] assert metadata.contract_version == plan.contract_version - assert metadata.contract_release_id == result.release_record_id + assert metadata.contract_release_id == result.contract_release_id def test_review_release_rejects_approval_for_different_bundle_digest(tmp_path) -> None: From 47c1df0323288cc66cce7df7efae7d8fbfc3ce6c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 15:51:12 +1000 Subject: [PATCH 152/616] docs(databricks): document release tag privileges --- docs/deployment_plans.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 501509cf..9ea47dc3 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -397,6 +397,8 @@ For example, a released `orders@1.4.0` table is tagged with version `1.4.0` and This projection is deliberately limited to SemaPact provenance. ODCS business tags, classifications, PII labels, or governed ABAC tags are **not** automatically mapped to Unity Catalog tags; those require a separate explicit mapping policy. +The Databricks principal running CD must have the Unity Catalog privileges needed to apply table tags, including `APPLY TAG` on the target object plus `USE CATALOG` and `USE SCHEMA` on its parents. SemaPact uses `ALTER TABLE ... SET TAGS`, supported by current Unity Catalog SQL surfaces. + Tag mutation is a provider side effect and therefore requires a Databricks SQL warehouse even when the schema deployment itself resolves to `NO_OP`. A tag-write failure fails the release deployment command rather than silently claiming the runtime is fully projected. ## Authorization scope From b41c9fc760e6416e2d2db538ee7929bf42233a4d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:06:09 +1000 Subject: [PATCH 153/616] feat(config): add typed operational history schema --- semapact/core/config_schema.py | 74 ++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 semapact/core/config_schema.py diff --git a/semapact/core/config_schema.py b/semapact/core/config_schema.py new file mode 100644 index 00000000..2848a0c2 --- /dev/null +++ b/semapact/core/config_schema.py @@ -0,0 +1,74 @@ +"""Typed configuration schema for optional operational deployment history.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator + + +class _OperationalHistoryConfig(BaseModel): + """Shared immutable configuration contract for operational history backends.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + backend: str + + +class SQLiteOperationalHistoryConfig(_OperationalHistoryConfig): + """Local SQLite operational history configuration.""" + + backend: Literal["sqlite"] + path: str + + @field_validator("path") + @classmethod + def _require_path(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("SQLite operational history path must not be empty") + return cleaned + + def as_uri(self) -> str: + return f"sqlite:///{self.path}" + + +class DeltaOperationalHistoryConfig(_OperationalHistoryConfig): + """Delta Lake operational history configuration.""" + + backend: Literal["delta"] + table_uri: str + + @field_validator("table_uri") + @classmethod + def _require_table_uri(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("Delta operational history table_uri must not be empty") + return cleaned + + def as_uri(self) -> str: + return f"delta:///{self.table_uri}" + + +OperationalHistoryConfig = Annotated[ + SQLiteOperationalHistoryConfig | DeltaOperationalHistoryConfig, + Field(discriminator="backend"), +] + +_OPERATIONAL_HISTORY_ADAPTER = TypeAdapter(OperationalHistoryConfig) + + +def parse_operational_history_config( + value: object, +) -> OperationalHistoryConfig | None: + """Validate the history.operational config subsection fail closed.""" + if value is None: + return None + return _OPERATIONAL_HISTORY_ADAPTER.validate_python(value) + + +def operational_history_uri_from_config(value: object) -> str | None: + """Return the normalized sink URI represented by typed configuration.""" + config = parse_operational_history_config(value) + return config.as_uri() if config is not None else None From d58fc4c679a6fecb24b236448d7413c74700c837 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:06:32 +1000 Subject: [PATCH 154/616] feat(config): resolve operational history from typed config --- .../interfaces/commands/deployment_cmd.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 24cd0a7a..3e296a22 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -174,7 +174,7 @@ def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: else None ) operational_history = create_operational_history_sink( - args.operational_history + _resolve_operational_history_uri(args.operational_history) ) metadata_projector = ( adapter @@ -201,6 +201,25 @@ def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: ) +def _resolve_operational_history_uri(cli_override: str | None) -> str | None: + """Resolve CLI override first, then typed project/global configuration.""" + if cli_override is not None and cli_override.strip(): + return cli_override.strip() + + from pydantic import ValidationError as PydanticConfigValidationError + + from semapact.core.config import config_manager + from semapact.core.config_schema import operational_history_uri_from_config + + raw_config = config_manager.get("history.operational", default=None) + try: + return operational_history_uri_from_config(raw_config) + except PydanticConfigValidationError as exc: + raise ValidationError( + f"Invalid history.operational configuration: {exc}" + ) from exc + + def _resolve_deployment_approval( bundle: DeploymentBundle, *, From 8ab4c8389f25f6b457cda617dc219fa5948e6ce6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:06:40 +1000 Subject: [PATCH 155/616] docs(cli): make operational history config-first --- semapact/interfaces/cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index 2b703822..1d5897cd 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -554,8 +554,9 @@ def _build_parser() -> argparse.ArgumentParser: deployment_deploy_parser.add_argument( "--operational-history", help=( - "Optional operational history backend URI: sqlite:///path/to/history.db " - "or delta:///path/to/table. Omit to disable deployment telemetry persistence." + "Optional operational-history URI override. When omitted, SemaPact reads " + "typed history.operational configuration; if neither is configured, " + "deployment telemetry persistence is disabled." ), ) deployment_deploy_parser.add_argument( From 7e9c9c6cfc087b195daee34c63bd0151e113b88c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:06:48 +1000 Subject: [PATCH 156/616] chore(config): declare optional operational history section --- .semapact.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.semapact.yaml b/.semapact.yaml index b1f82569..57601a98 100644 --- a/.semapact.yaml +++ b/.semapact.yaml @@ -17,3 +17,7 @@ llm: model_name: gpt-4-turbo api_key: '' base_url: '' + +history: + # Disabled by default. Configure a typed backend when deployment telemetry is needed. + operational: null From 41e810d07ce471a661ac9136b750f3df302532f1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:06:54 +1000 Subject: [PATCH 157/616] feat(history): version operational event schema --- semapact/history/operational.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/semapact/history/operational.py b/semapact/history/operational.py index 809d3459..a2dd4ab0 100644 --- a/semapact/history/operational.py +++ b/semapact/history/operational.py @@ -28,6 +28,7 @@ class OperationalDeploymentEvent(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") event_id: str + event_version: Literal["1"] = "1" bundle_digest: str release: bool contract_release_id: str | None = None @@ -98,6 +99,7 @@ def _validate_event(self) -> "OperationalDeploymentEvent": if self.status == "FAILED" and self.error_message is None: raise ValueError("Failed deployment event requires error_message") expected = compute_operational_deployment_event_id( + event_version=self.event_version, bundle_digest=self.bundle_digest, release=self.release, contract_release_id=self.contract_release_id, @@ -155,6 +157,7 @@ def build_operational_deployment_event( ) -> OperationalDeploymentEvent: normalized_platform = platform.strip().casefold() event_id = compute_operational_deployment_event_id( + event_version="1", bundle_digest=bundle_digest, release=release, contract_release_id=contract_release_id, @@ -177,6 +180,7 @@ def build_operational_deployment_event( ) return OperationalDeploymentEvent( event_id=event_id, + event_version="1", bundle_digest=bundle_digest, release=release, contract_release_id=contract_release_id, From b4e2c062962102934f39e4c7816b00edd6e41be6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:07:15 +1000 Subject: [PATCH 158/616] test(config): validate operational history schema --- tests/test_operational_history_config.py | 57 ++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/test_operational_history_config.py diff --git a/tests/test_operational_history_config.py b/tests/test_operational_history_config.py new file mode 100644 index 00000000..6c61f7a8 --- /dev/null +++ b/tests/test_operational_history_config.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError as PydanticValidationError + +from semapact.core.config_schema import ( + DeltaOperationalHistoryConfig, + SQLiteOperationalHistoryConfig, + operational_history_uri_from_config, + parse_operational_history_config, +) + + +def test_operational_history_config_is_disabled_when_omitted() -> None: + assert parse_operational_history_config(None) is None + assert operational_history_uri_from_config(None) is None + + +def test_sqlite_operational_history_config_is_typed() -> None: + config = parse_operational_history_config( + { + "backend": "sqlite", + "path": ".semapact/operational.db", + } + ) + + assert isinstance(config, SQLiteOperationalHistoryConfig) + assert config.as_uri() == "sqlite:///.semapact/operational.db" + + +def test_delta_operational_history_config_is_typed() -> None: + config = parse_operational_history_config( + { + "backend": "delta", + "table_uri": "s3://governance/semapact/history", + } + ) + + assert isinstance(config, DeltaOperationalHistoryConfig) + assert ( + config.as_uri() + == "delta:///s3://governance/semapact/history" + ) + + +@pytest.mark.parametrize( + "payload", + [ + {"backend": "sqlite"}, + {"backend": "delta"}, + {"backend": "git", "path": ".semapact/history"}, + {"backend": "sqlite", "path": "", "extra": "forbidden"}, + ], +) +def test_invalid_operational_history_config_fails_closed(payload) -> None: + with pytest.raises(PydanticValidationError): + parse_operational_history_config(payload) From a93490ccbcb076e7042bd52be86b40948c74f7fb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:07:26 +1000 Subject: [PATCH 159/616] test(cli): resolve operational history from config with override precedence --- tests/interfaces/test_deployment_cmd.py | 60 +++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index 29b50803..6ff90471 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -178,3 +178,63 @@ def test_assessment_source_reference_cannot_override_contract_host() -> None: server, "https://other-workspace.example", ) + + +def test_operational_history_cli_override_takes_precedence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from semapact.core.config import config_manager + + monkeypatch.setattr( + config_manager, + "get", + lambda *args, **kwargs: { + "backend": "sqlite", + "path": ".semapact/from-config.db", + }, + ) + + assert ( + deployment_cmd._resolve_operational_history_uri( + "sqlite:///explicit.db" + ) + == "sqlite:///explicit.db" + ) + + +def test_operational_history_falls_back_to_typed_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from semapact.core.config import config_manager + + monkeypatch.setattr( + config_manager, + "get", + lambda *args, **kwargs: { + "backend": "sqlite", + "path": ".semapact/operational.db", + }, + ) + + assert ( + deployment_cmd._resolve_operational_history_uri(None) + == "sqlite:///.semapact/operational.db" + ) + + +def test_invalid_operational_history_config_fails_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from semapact.core.config import config_manager + + monkeypatch.setattr( + config_manager, + "get", + lambda *args, **kwargs: { + "backend": "git", + "path": ".semapact/history", + }, + ) + + with pytest.raises(ValidationError, match="history.operational"): + deployment_cmd._resolve_operational_history_uri(None) From 0ca233ef308d813c6a5059d2350f6cd459139039 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:07:35 +1000 Subject: [PATCH 160/616] test(history): pin operational event schema version --- tests/test_operational_history.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_operational_history.py b/tests/test_operational_history.py index d2a27bf6..569e0e71 100644 --- a/tests/test_operational_history.py +++ b/tests/test_operational_history.py @@ -46,6 +46,7 @@ def test_sqlite_operational_history_is_idempotent(tmp_path) -> None: assert isinstance(sink, SQLiteOperationalHistorySink) event = _event() + assert event.event_version == "1" sink.record_deployment(event) sink.record_deployment(event) From 5e485aa32935bdcb17d5296aadea86ca66721c17 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:07:49 +1000 Subject: [PATCH 161/616] feat(config): expose root schema for operational history settings --- semapact/core/config_schema.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/semapact/core/config_schema.py b/semapact/core/config_schema.py index 2848a0c2..6c23cadb 100644 --- a/semapact/core/config_schema.py +++ b/semapact/core/config_schema.py @@ -56,6 +56,23 @@ def as_uri(self) -> str: Field(discriminator="backend"), ] + + +class HistoryConfig(BaseModel): + """Typed history configuration while unrelated legacy config remains extensible.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + operational: OperationalHistoryConfig | None = None + + +class SemaPactConfigSchema(BaseModel): + """Incremental root schema for project/global SemaPact configuration.""" + + model_config = ConfigDict(extra="allow") + + history: HistoryConfig | None = None + _OPERATIONAL_HISTORY_ADAPTER = TypeAdapter(OperationalHistoryConfig) From dc60ae878ec0e9c9eaa989d582c73fd20777d78c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:08:00 +1000 Subject: [PATCH 162/616] docs(config): make operational history project-config first --- README.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0cad2c7d..5eb68f38 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ A released version is environment-neutral. The same version can later be deploye `deploy` always re-observes runtime before execution; CI-time preview operations are never replayed blindly. -Deployment telemetry is disabled by default. High-frequency execution history can be enabled explicitly with a SQLite or Delta operational-history backend; it is not written into Git governance history. +Deployment telemetry is disabled by default. High-frequency execution history can be enabled once in typed `.semapact.yaml` configuration with a SQLite or Delta backend; it is not written into Git governance history. The `--operational-history` CLI option is only an override. For Databricks, once a **formal release** is verified `IN_SYNC`, SemaPact projects release provenance to governed Unity Catalog tables using reserved tags: @@ -397,7 +397,27 @@ semapact deployment deploy \ --warehouse-id ``` -Formal REVIEW releases resolve exact approval evidence from Git-backed history unless `--approval` is supplied explicitly. Candidate deployments do not require release approval. Operational deployment history is optional via `--operational-history sqlite:///...` or `delta:///...`. +Formal REVIEW releases resolve exact approval evidence from Git-backed history unless `--approval` is supplied explicitly. Candidate deployments do not require release approval. + +Operational deployment history is configured project-wide rather than repeated on every deploy: + +```yaml +history: + operational: + backend: sqlite + path: .semapact/operational.db +``` + +For a shared Delta sink: + +```yaml +history: + operational: + backend: delta + table_uri: s3://governance/semapact/operational-history +``` + +The config is fail-closed against the typed `SemaPactConfigSchema`. `--operational-history` remains available only as a per-invocation override. If neither config nor override is present, operational persistence stays disabled. ## Optional Dependencies From 74392a1d881e854e5594379b1ee7fec5cdc889bf Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:08:12 +1000 Subject: [PATCH 163/616] docs(config): define typed operational history configuration --- docs/deployment_plans.md | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 9ea47dc3..ca998993 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -349,21 +349,39 @@ deployment execute + verify → no operational history persistence ``` -To persist operational telemetry explicitly: +Configure operational telemetry once in `.semapact.yaml`: -```bash -# local / lightweight -semapact deployment deploy \ - --bundle ./artifacts/orders-dev.bundle.json \ - --operational-history sqlite:///./.semapact/operational.db +```yaml +history: + operational: + backend: sqlite + path: .semapact/operational.db +``` -# shared / higher-volume -semapact deployment deploy \ - --bundle ./artifacts/orders-prod.bundle.json \ - --operational-history delta:///path/to/semapact_operational_history +For a shared/higher-volume Delta sink: + +```yaml +history: + operational: + backend: delta + table_uri: s3://governance/semapact/operational-history ``` -Supported operational history backends in this slice are SQLite and Delta. The Delta backend is lazy and requires the `delta` optional extra. +The `history.operational` subsection is validated fail-closed by the typed `SemaPactConfigSchema`. Unknown backends, missing backend-specific fields, or extra fields are rejected. + +Resolution precedence is: + +```text +--operational-history CLI override + ↓ +history.operational project/global config + ↓ +disabled +``` + +The CLI URI remains available for one-off/custom pipeline overrides, but ordinary CI/CD does not repeat the history destination on every deployment. + +Supported operational history backends in this slice are SQLite and Delta. The Delta backend is lazy and requires the `delta` optional extra. Operational event payloads carry an explicit schema version so the telemetry contract can evolve independently of governance-history models. Legacy Git-backed deployment/runtime history artifacts remain readable for compatibility, but the canonical bundle workflow does not create new operational records in Git. From 2afcfdf816f8720a4e407f8067ed7b8f37d8ce8f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:08:20 +1000 Subject: [PATCH 164/616] test(config): expose operational history in root config schema --- tests/test_operational_history_config.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_operational_history_config.py b/tests/test_operational_history_config.py index 6c61f7a8..2e94e5d4 100644 --- a/tests/test_operational_history_config.py +++ b/tests/test_operational_history_config.py @@ -6,6 +6,7 @@ from semapact.core.config_schema import ( DeltaOperationalHistoryConfig, SQLiteOperationalHistoryConfig, + SemaPactConfigSchema, operational_history_uri_from_config, parse_operational_history_config, ) @@ -55,3 +56,13 @@ def test_delta_operational_history_config_is_typed() -> None: def test_invalid_operational_history_config_fails_closed(payload) -> None: with pytest.raises(PydanticValidationError): parse_operational_history_config(payload) + + +def test_root_config_schema_exposes_operational_backend_discriminator() -> None: + schema = SemaPactConfigSchema.model_json_schema() + + assert "history" in schema["properties"] + rendered = str(schema) + assert "sqlite" in rendered + assert "delta" in rendered + assert "discriminator" in rendered From 59455e7f51ff32469cee860d59bb1cff3dcc9565 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:09:21 +1000 Subject: [PATCH 165/616] fix(config): do not shadow global operational history defaults --- .semapact.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.semapact.yaml b/.semapact.yaml index 57601a98..f1e9f0b6 100644 --- a/.semapact.yaml +++ b/.semapact.yaml @@ -18,6 +18,3 @@ llm: api_key: '' base_url: '' -history: - # Disabled by default. Configure a typed backend when deployment telemetry is needed. - operational: null From ac1c9f537aee4dbc4711dfab2b6b493a491cbb13 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:09:46 +1000 Subject: [PATCH 166/616] docs(config): publish SemaPact configuration JSON Schema --- schemas/semapact-config.schema.json | 68 +++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 schemas/semapact-config.schema.json diff --git a/schemas/semapact-config.schema.json b/schemas/semapact-config.schema.json new file mode 100644 index 00000000..25ed399c --- /dev/null +++ b/schemas/semapact-config.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://semapact.org/schemas/semapact-config.schema.json", + "title": "SemaPact configuration", + "type": "object", + "additionalProperties": true, + "properties": { + "history": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "properties": { + "operational": { + "oneOf": [ + { + "$ref": "#/$defs/SQLiteOperationalHistoryConfig" + }, + { + "$ref": "#/$defs/DeltaOperationalHistoryConfig" + }, + { + "type": "null" + } + ], + "default": null + } + } + } + }, + "$defs": { + "SQLiteOperationalHistoryConfig": { + "type": "object", + "additionalProperties": false, + "required": [ + "backend", + "path" + ], + "properties": { + "backend": { + "const": "sqlite" + }, + "path": { + "type": "string", + "minLength": 1 + } + } + }, + "DeltaOperationalHistoryConfig": { + "type": "object", + "additionalProperties": false, + "required": [ + "backend", + "table_uri" + ], + "properties": { + "backend": { + "const": "delta" + }, + "table_uri": { + "type": "string", + "minLength": 1 + } + } + } + } +} From 217279d1bf8cf795e495079823c5e3567fe34247 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:10:00 +1000 Subject: [PATCH 167/616] test(config): validate published configuration schema --- tests/test_operational_history_config.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_operational_history_config.py b/tests/test_operational_history_config.py index 2e94e5d4..76369ec5 100644 --- a/tests/test_operational_history_config.py +++ b/tests/test_operational_history_config.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +from pathlib import Path import pytest from pydantic import ValidationError as PydanticValidationError @@ -66,3 +68,14 @@ def test_root_config_schema_exposes_operational_backend_discriminator() -> None: assert "sqlite" in rendered assert "delta" in rendered assert "discriminator" in rendered + + +def test_published_config_json_schema_covers_operational_history() -> None: + schema = json.loads( + Path("schemas/semapact-config.schema.json").read_text(encoding="utf-8") + ) + + operational = schema["properties"]["history"]["properties"]["operational"] + assert len(operational["oneOf"]) == 3 + assert "SQLiteOperationalHistoryConfig" in schema["$defs"] + assert "DeltaOperationalHistoryConfig" in schema["$defs"] From 0d49c1a011db864e14f682f4d92443e6a9bd7890 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:10:15 +1000 Subject: [PATCH 168/616] docs(config): document typed operational history settings --- docs/configuration.md | 59 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/configuration.md diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..9c1b0d79 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,59 @@ +# Configuration + +SemaPact loads configuration in this order: + +1. explicit CLI override for the current operation; +2. local project config at `.semapact.yaml`; +3. global config at `~/.config/semapact/config.yaml`; +4. feature default. + +Operational deployment history is disabled when no backend is configured. + +## Operational history + +SQLite: + +```yaml +history: + operational: + backend: sqlite + path: .semapact/operational.db +``` + +Delta: + +```yaml +history: + operational: + backend: delta + table_uri: s3://governance/semapact/operational-history +``` + +The configuration is validated fail closed. Supported backends require exactly their backend-specific fields: + +- `sqlite`: `backend` + `path`; +- `delta`: `backend` + `table_uri`. + +Unknown backends, missing required fields, and extra fields under `history.operational` are rejected. + +The canonical schema is defined by `SemaPactConfigSchema` and published for editor/tooling use at: + +```text +schemas/semapact-config.schema.json +``` + +## CLI override + +`--operational-history` is a per-invocation override, not the normal configuration path. + +For example: + +```bash +semapact deployment deploy \ + --bundle ./artifacts/orders.bundle.json \ + --operational-history sqlite:///./tmp/one-run.db +``` + +If the flag is omitted, SemaPact resolves `history.operational` from project/global config. If neither exists, operational history persistence remains disabled. + +Operational telemetry is separate from the Git governance ledger. Formal release/approval facts may be stored in Git, while high-frequency deployment events are written only to an explicitly configured SQLite or Delta backend. From 0065f23b43429ee67694783b16ecda33448156de Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:10:24 +1000 Subject: [PATCH 169/616] docs(readme): link typed configuration reference --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5eb68f38..d5d4e4b6 100644 --- a/README.md +++ b/README.md @@ -417,7 +417,7 @@ history: table_uri: s3://governance/semapact/operational-history ``` -The config is fail-closed against the typed `SemaPactConfigSchema`. `--operational-history` remains available only as a per-invocation override. If neither config nor override is present, operational persistence stays disabled. +The config is fail-closed against the typed `SemaPactConfigSchema`. `--operational-history` remains available only as a per-invocation override. If neither config nor override is present, operational persistence stays disabled. See [`docs/configuration.md`](docs/configuration.md) for precedence and schema details. ## Optional Dependencies From 280070bac9cf1d21f50f6eb9658002bc12434c4e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 16:10:58 +1000 Subject: [PATCH 170/616] style(config): normalize schema module spacing --- semapact/core/config_schema.py | 1 - 1 file changed, 1 deletion(-) diff --git a/semapact/core/config_schema.py b/semapact/core/config_schema.py index 6c23cadb..a849e77f 100644 --- a/semapact/core/config_schema.py +++ b/semapact/core/config_schema.py @@ -57,7 +57,6 @@ def as_uri(self) -> str: ] - class HistoryConfig(BaseModel): """Typed history configuration while unrelated legacy config remains extensible.""" From 71a54893f64b4eccf7d9fcf21b9b39d4836817c5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:14:46 +1000 Subject: [PATCH 171/616] docs(examples): add data product GitHub CI CD workflow --- examples/github/data-product-ci-cd.yml | 185 +++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 examples/github/data-product-ci-cd.yml diff --git a/examples/github/data-product-ci-cd.yml b/examples/github/data-product-ci-cd.yml new file mode 100644 index 00000000..f9701285 --- /dev/null +++ b/examples/github/data-product-ci-cd.yml @@ -0,0 +1,185 @@ +name: SemaPact - data product CI/CD + +on: + pull_request: + paths: + - contract.yaml + push: + branches: + - main + paths: + - contract.yaml + +permissions: + contents: write + +env: + PYTHON_VERSION: "3.11" + CONTRACT_PATH: contract.yaml + DEV_SERVER: development + PROD_SERVER: production + +jobs: + candidate-assess: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + env: + DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} + steps: + - name: Checkout candidate + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install SemaPact + run: pip install "semapact[databricks]" + + - name: Materialize base contract + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git show "${BASE_SHA}:${CONTRACT_PATH}" > /tmp/base-contract.yaml + + - name: Assess candidate against development + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + CANDIDATE_SHA: ${{ github.event.pull_request.head.sha }} + run: | + mkdir -p artifacts + semapact deployment assess \ + --base /tmp/base-contract.yaml \ + --candidate "${CONTRACT_PATH}" \ + --base-revision-ref "git:${BASE_SHA}" \ + --candidate-revision-ref "git:${CANDIDATE_SHA}" \ + --effective-date "$(date -u +%F)" \ + --server "${DEV_SERVER}" \ + --bundle-out artifacts/candidate.bundle.json \ + --output json > artifacts/candidate.summary.json + + - name: Upload candidate assessment + uses: actions/upload-artifact@v4 + with: + name: semapact-candidate-assessment + path: artifacts/ + + release-assess: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + env: + DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} + steps: + - name: Checkout release candidate + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install SemaPact + run: pip install "semapact[databricks]" + + - name: Materialize previous contract + env: + BASE_SHA: ${{ github.event.before }} + run: | + git show "${BASE_SHA}:${CONTRACT_PATH}" > /tmp/base-contract.yaml + + - name: Build formal release bundle + env: + BASE_SHA: ${{ github.event.before }} + CANDIDATE_SHA: ${{ github.sha }} + run: | + mkdir -p artifacts + semapact deployment assess \ + --base /tmp/base-contract.yaml \ + --candidate "${CONTRACT_PATH}" \ + --base-revision-ref "git:${BASE_SHA}" \ + --candidate-revision-ref "git:${CANDIDATE_SHA}" \ + --effective-date "$(date -u +%F)" \ + --server "${PROD_SERVER}" \ + --release \ + --bundle-out artifacts/release.bundle.json \ + --output json > artifacts/release.summary.json + + - name: Upload exact release bundle + uses: actions/upload-artifact@v4 + with: + name: semapact-release-bundle + path: artifacts/ + + deploy-production: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: release-assess + runs-on: ubuntu-latest + environment: production + env: + DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} + DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }} + steps: + - name: Checkout governance ledger + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install SemaPact + run: pip install "semapact[databricks]" + + - name: Download exact release bundle + uses: actions/download-artifact@v4 + with: + name: semapact-release-bundle + path: artifacts + + # GitHub Environment is the human approval surface. Today the generic + # approval CLI still needs the exact bundle fields projected into the + # Git governance ledger; this is intentionally visible in the example. + - name: Record environment approval when governance requires REVIEW + run: | + BUNDLE=artifacts/release.bundle.json + if [ "$(jq -r '.decision.decision' "${BUNDLE}")" = "REVIEW" ]; then + semapact approval record \ + --decision-id "$(jq -r '.decision.decision_id' "${BUNDLE}")" \ + --change-set-id "$(jq -r '.change_set.change_set_id' "${BUNDLE}")" \ + --release-plan-id "$(jq -r '.release_plan.release_plan_id' "${BUNDLE}")" \ + --version-resolution-id "$(jq -r '.version_resolution.version_resolution_id' "${BUNDLE}")" \ + --operation DEPLOY \ + --action APPROVE \ + --actor-reference "github-environment:production/run:${GITHUB_RUN_ID}" \ + --recorded-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --scope-reference "$(jq -r '.deployment_plan.deployment_plan_id' "${BUNDLE}")" \ + --capability-reference "github-environment:production" \ + --evidence-reference "$(jq -r '.bundle_digest' "${BUNDLE}")" + fi + + - name: Deploy exact release bundle + run: | + semapact deployment deploy \ + --bundle artifacts/release.bundle.json \ + --warehouse-id "${DATABRICKS_WAREHOUSE_ID}" + + - name: Commit governance ledger + run: | + if [ -n "$(git status --porcelain .semapact/history 2>/dev/null)" ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .semapact/history + git commit -m "chore(semapact): record contract release" + git pull --rebase origin main + git push origin HEAD:main + fi + + # Operational deployment history is not passed here. If configured under + # history.operational in .semapact.yaml, deploy writes to that backend. From d9f444b5c7b39ae0d3dc6ddc725b190b0057df3c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:15:54 +1000 Subject: [PATCH 172/616] docs(examples): add central contract repo GitHub CI CD workflow --- .../github/central-contract-repo-ci-cd.yml | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 examples/github/central-contract-repo-ci-cd.yml diff --git a/examples/github/central-contract-repo-ci-cd.yml b/examples/github/central-contract-repo-ci-cd.yml new file mode 100644 index 00000000..136cdc96 --- /dev/null +++ b/examples/github/central-contract-repo-ci-cd.yml @@ -0,0 +1,307 @@ +name: SemaPact - central contract repository CI/CD + +on: + pull_request: + paths: + - contracts/**/*.yaml + - contracts/**/*.yml + push: + branches: + - main + paths: + - contracts/**/*.yaml + - contracts/**/*.yml + +permissions: + contents: write + +env: + PYTHON_VERSION: "3.11" + CONTRACT_ROOT: contracts + DEV_SERVER: development + PROD_SERVER: production + +jobs: + detect-contracts: + runs-on: ubuntu-latest + outputs: + base_sha: ${{ steps.base.outputs.sha }} + matrix: ${{ steps.matrix.outputs.matrix }} + has_changes: ${{ steps.matrix.outputs.has_changes }} + steps: + - name: Checkout candidate repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install SemaPact + run: pip install semapact + + - name: Resolve comparison base + id: base + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + else + BASE_SHA="${{ github.event.before }}" + fi + echo "sha=${BASE_SHA}" >> "${GITHUB_OUTPUT}" + git worktree add --detach /tmp/semapact-base "${BASE_SHA}" + + - name: Classify repository contract changes + run: | + mkdir -p artifacts + semapact release classify-repo \ + --base-root "/tmp/semapact-base/${CONTRACT_ROOT}" \ + --candidate-root "${CONTRACT_ROOT}" \ + --effective-date "$(date -u +%F)" \ + > artifacts/repository-changes.json + cat artifacts/repository-changes.json + + - name: Fail closed on unsupported repository changes + run: | + PROBLEMS=$(jq -c '[.contracts[] | select(.status == "added" or .status == "removed" or .status == "blocked")]' artifacts/repository-changes.json) + if [ "${PROBLEMS}" != "[]" ]; then + echo "Repository changes require explicit handling before deployment:" + echo "${PROBLEMS}" | jq . + exit 1 + fi + + - name: Build changed-contract matrix + id: matrix + run: | + MATRIX=$(jq -c '{include: [.contracts[] + | select(.status == "changed") + | {contract_path: .contractRepoPath, artifact: (.contractRepoPath | gsub("[^A-Za-z0-9_-]"; "_"))}]}' artifacts/repository-changes.json) + COUNT=$(echo "${MATRIX}" | jq '.include | length') + echo "matrix=${MATRIX}" >> "${GITHUB_OUTPUT}" + if [ "${COUNT}" -gt 0 ]; then + echo "has_changes=true" >> "${GITHUB_OUTPUT}" + else + echo "has_changes=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Upload repository classification + uses: actions/upload-artifact@v4 + with: + name: semapact-repository-classification + path: artifacts/repository-changes.json + + candidate-assess: + if: github.event_name == 'pull_request' && needs.detect-contracts.outputs.has_changes == 'true' + needs: detect-contracts + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.detect-contracts.outputs.matrix) }} + env: + DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} + steps: + - name: Checkout candidate repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install SemaPact + run: pip install "semapact[databricks]" + + - name: Materialize base contract + env: + BASE_SHA: ${{ needs.detect-contracts.outputs.base_sha }} + CONTRACT_PATH: ${{ matrix.contract_path }} + run: | + git show "${BASE_SHA}:${CONTRACT_ROOT}/${CONTRACT_PATH}" > /tmp/base-contract.yaml + + - name: Assess candidate contract + env: + BASE_SHA: ${{ needs.detect-contracts.outputs.base_sha }} + CANDIDATE_SHA: ${{ github.event.pull_request.head.sha }} + CONTRACT_PATH: ${{ matrix.contract_path }} + ARTIFACT: ${{ matrix.artifact }} + run: | + mkdir -p artifacts + semapact deployment assess \ + --base /tmp/base-contract.yaml \ + --candidate "${CONTRACT_ROOT}/${CONTRACT_PATH}" \ + --base-revision-ref "git:${BASE_SHA}" \ + --candidate-revision-ref "git:${CANDIDATE_SHA}" \ + --effective-date "$(date -u +%F)" \ + --server "${DEV_SERVER}" \ + --bundle-out "artifacts/${ARTIFACT}.candidate.bundle.json" \ + --output json > "artifacts/${ARTIFACT}.candidate.summary.json" + + - name: Upload candidate bundle + uses: actions/upload-artifact@v4 + with: + name: candidate-${{ matrix.artifact }} + path: artifacts/ + + release-assess: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-contracts.outputs.has_changes == 'true' + needs: detect-contracts + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.detect-contracts.outputs.matrix) }} + env: + DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} + steps: + - name: Checkout release candidate + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install SemaPact + run: pip install "semapact[databricks]" + + - name: Materialize previous contract + env: + BASE_SHA: ${{ needs.detect-contracts.outputs.base_sha }} + CONTRACT_PATH: ${{ matrix.contract_path }} + run: | + git show "${BASE_SHA}:${CONTRACT_ROOT}/${CONTRACT_PATH}" > /tmp/base-contract.yaml + + - name: Build formal release bundle + env: + BASE_SHA: ${{ needs.detect-contracts.outputs.base_sha }} + CANDIDATE_SHA: ${{ github.sha }} + CONTRACT_PATH: ${{ matrix.contract_path }} + ARTIFACT: ${{ matrix.artifact }} + run: | + mkdir -p artifacts + semapact deployment assess \ + --base /tmp/base-contract.yaml \ + --candidate "${CONTRACT_ROOT}/${CONTRACT_PATH}" \ + --base-revision-ref "git:${BASE_SHA}" \ + --candidate-revision-ref "git:${CANDIDATE_SHA}" \ + --effective-date "$(date -u +%F)" \ + --server "${PROD_SERVER}" \ + --release \ + --bundle-out "artifacts/${ARTIFACT}.release.bundle.json" \ + --output json > "artifacts/${ARTIFACT}.release.summary.json" + + - name: Upload exact release bundle + uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.artifact }} + path: artifacts/ + + deploy-production: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-contracts.outputs.has_changes == 'true' + needs: + - detect-contracts + - release-assess + runs-on: ubuntu-latest + environment: production + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.detect-contracts.outputs.matrix) }} + env: + DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} + DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }} + steps: + - name: Checkout governance ledger + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install SemaPact + run: pip install "semapact[databricks]" + + - name: Download exact contract release bundle + uses: actions/download-artifact@v4 + with: + name: release-${{ matrix.artifact }} + path: artifacts + + # This projection is deliberately verbose: it exposes the current gap + # between a GitHub Environment approval event and SemaPact ApprovalRecord. + - name: Record environment approval when governance requires REVIEW + env: + ARTIFACT: ${{ matrix.artifact }} + run: | + BUNDLE="artifacts/${ARTIFACT}.release.bundle.json" + if [ "$(jq -r '.decision.decision' "${BUNDLE}")" = "REVIEW" ]; then + semapact approval record \ + --decision-id "$(jq -r '.decision.decision_id' "${BUNDLE}")" \ + --change-set-id "$(jq -r '.change_set.change_set_id' "${BUNDLE}")" \ + --release-plan-id "$(jq -r '.release_plan.release_plan_id' "${BUNDLE}")" \ + --version-resolution-id "$(jq -r '.version_resolution.version_resolution_id' "${BUNDLE}")" \ + --operation DEPLOY \ + --action APPROVE \ + --actor-reference "github-environment:production/run:${GITHUB_RUN_ID}" \ + --recorded-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --scope-reference "$(jq -r '.deployment_plan.deployment_plan_id' "${BUNDLE}")" \ + --capability-reference "github-environment:production" \ + --evidence-reference "$(jq -r '.bundle_digest' "${BUNDLE}")" + fi + + - name: Deploy contract release + env: + ARTIFACT: ${{ matrix.artifact }} + run: | + semapact deployment deploy \ + --bundle "artifacts/${ARTIFACT}.release.bundle.json" \ + --warehouse-id "${DATABRICKS_WAREHOUSE_ID}" + + - name: Upload governance-ledger fragment + if: always() + uses: actions/upload-artifact@v4 + with: + name: ledger-${{ matrix.artifact }} + path: .semapact/history/ + if-no-files-found: ignore + + # Operational history is config-first. No per-contract CLI flag is needed + # when history.operational is present in .semapact.yaml. + + commit-governance-ledger: + if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-contracts.outputs.has_changes == 'true' && needs.release-assess.result == 'success' + needs: + - detect-contracts + - release-assess + - deploy-production + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download governance-ledger fragments + uses: actions/download-artifact@v4 + with: + pattern: ledger-* + path: .semapact/history + merge-multiple: true + + - name: Commit governance ledger once + run: | + if [ -n "$(git status --porcelain .semapact/history 2>/dev/null)" ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .semapact/history + git commit -m "chore(semapact): record contract releases" + git pull --rebase origin main + git push origin HEAD:main + fi From 423b8c8a230be9367da1a3ddadfe9a0e3fee14d2 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:16:26 +1000 Subject: [PATCH 173/616] docs(examples): document bundle-driven GitHub workflows --- examples/README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 37835c01..70290e5d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,10 +16,14 @@ This folder contains reference assets for wiring SemaPact into CI/CD. ## GitHub Actions Examples +- `github/data-product-ci-cd.yml` + - end-to-end candidate CI + formal-release CD for a standard data-product repo +- `github/central-contract-repo-ci-cd.yml` + - changed-contract discovery, matrix assessment/deployment, and single-write governance ledger for a centralised contract repo - `github/semapact-enrich.yml` - on-demand LLM contract enrichment trigger - `github/semapact-release.yml` - - release promotion trigger + - legacy release-promotion example ## Azure DevOps Examples @@ -34,3 +38,14 @@ Important rules reflected by these examples: - PR builds classify changes but do not bump contract versions - release builds apply explicit release tags only for contracts that require a bump - contracts with `required_bump = none` are skipped by default in batch release manifests + + +## Bundle-driven CI/CD examples + +The two bundle-driven GitHub examples intentionally use only public SemaPact CLI surfaces plus ordinary GitHub Actions/Git commands. They are also architecture smoke tests: any large amount of JSON extraction or repository glue is a signal that the CLI boundary may need simplification. + +Both examples assume contracts define Databricks servers named `development` and `production`; adapt those names to local contract conventions. + +Operational deployment history is config-first through `.semapact.yaml`. The examples do not repeat `--operational-history` on every deployment. + +GitHub Environment protection is used as the human production approval surface. Until SemaPact has a provider-native approval-ingestion command, the examples explicitly project a REVIEW bundle into the generic `semapact approval record` CLI before deployment. From 442d129baa0e0a343891413484b8799060c8cec7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:16:36 +1000 Subject: [PATCH 174/616] test(examples): cover bundle-driven GitHub CI CD workflows --- .../test_semapact_devops_examples_readable.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_semapact_devops_examples_readable.py b/tests/test_semapact_devops_examples_readable.py index 8687bab2..b7e645c1 100644 --- a/tests/test_semapact_devops_examples_readable.py +++ b/tests/test_semapact_devops_examples_readable.py @@ -40,3 +40,33 @@ def test_azure_devops_examples_reference_release_commands(): assert "release classify-repo" in pr_pipeline assert "release build-manifest" in release_pipeline assert "release create-prs" in release_pipeline + + +def test_data_product_github_example_uses_bundle_driven_ci_cd() -> None: + workflow = Path("examples/github/data-product-ci-cd.yml").read_text( + encoding="utf-8" + ) + + assert "deployment assess" in workflow + assert "--release" in workflow + assert "deployment deploy" in workflow + assert "approval record" in workflow + assert "environment: production" in workflow + assert "--operational-history" not in workflow + assert "contract.release.bundle" not in workflow + + +def test_central_contract_repo_github_example_fans_out_and_commits_ledger_once() -> None: + workflow = Path("examples/github/central-contract-repo-ci-cd.yml").read_text( + encoding="utf-8" + ) + + assert "release classify-repo" in workflow + assert "fromJSON(needs.detect-contracts.outputs.matrix)" in workflow + assert "deployment assess" in workflow + assert "--release" in workflow + assert "deployment deploy" in workflow + assert "approval record" in workflow + assert "commit-governance-ledger:" in workflow + assert "merge-multiple: true" in workflow + assert "--operational-history" not in workflow From 8871b36074e4375b480d72ab2d707fa83eaeeeaf Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:16:47 +1000 Subject: [PATCH 175/616] test(examples): parse bundle-driven GitHub workflows as YAML --- tests/test_semapact_devops_examples_readable.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_semapact_devops_examples_readable.py b/tests/test_semapact_devops_examples_readable.py index b7e645c1..fd3ede5b 100644 --- a/tests/test_semapact_devops_examples_readable.py +++ b/tests/test_semapact_devops_examples_readable.py @@ -3,6 +3,8 @@ import json from pathlib import Path +import yaml + def test_release_manifest_example_is_valid_json_array(): manifest_path = Path("examples/release/release-manifest.example.json") @@ -70,3 +72,14 @@ def test_central_contract_repo_github_example_fans_out_and_commits_ledger_once() assert "commit-governance-ledger:" in workflow assert "merge-multiple: true" in workflow assert "--operational-history" not in workflow + + +def test_bundle_driven_github_examples_are_valid_yaml() -> None: + for path in ( + Path("examples/github/data-product-ci-cd.yml"), + Path("examples/github/central-contract-repo-ci-cd.yml"), + ): + payload = yaml.safe_load(path.read_text(encoding="utf-8")) + assert isinstance(payload, dict) + assert isinstance(payload.get("jobs"), dict) + assert payload["jobs"] From 4c74f2a5773265fba68558f6d04e8f448fa3905c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:36:42 +1000 Subject: [PATCH 176/616] feat(release): add target-neutral immutable release bundle --- semapact/application/models/release.py | 109 ++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/semapact/application/models/release.py b/semapact/application/models/release.py index fa4d4546..d028fe6a 100644 --- a/semapact/application/models/release.py +++ b/semapact/application/models/release.py @@ -1,11 +1,16 @@ -"""Application result models for canonical release orchestration.""" +"""Application artifacts for target-neutral contract release workflows.""" from __future__ import annotations +import hashlib from dataclasses import dataclass +from typing import Literal -from semapact.contractops import ChangeSet, ReleasePlan, VersionResolution +from pydantic import BaseModel, ConfigDict, model_validator + +from semapact.contractops import ChangeSet, ReleasePlan, ReleaseSnapshot, VersionResolution from semapact.governance import GovernanceDecision +from semapact.utils.deterministic import canonical_compact_json @dataclass(frozen=True) @@ -16,3 +21,103 @@ class ReleasePlanningResult: decision: GovernanceDecision release_plan: ReleasePlan version_resolution: VersionResolution + + +class ReleaseBundle(BaseModel): + """Immutable target-neutral CI artifact for one formal contract release.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + bundle_digest: str + decision: GovernanceDecision + change_set: ChangeSet + release_plan: ReleasePlan + version_resolution: VersionResolution + release_snapshot: ReleaseSnapshot + bundle_version: Literal["1"] = "1" + + @model_validator(mode="after") + def _validate_links(self) -> "ReleaseBundle": + if self.change_set.contract_id != self.decision.contract_id: + raise ValueError("ReleaseBundle decision/change-set contract mismatch") + if self.release_plan.change_set_id != self.change_set.change_set_id: + raise ValueError("ReleaseBundle release plan does not match ChangeSet") + if self.release_plan.decision_id != self.decision.decision_id: + raise ValueError("ReleaseBundle release plan does not match decision") + if self.version_resolution.release_plan_id != self.release_plan.release_plan_id: + raise ValueError( + "ReleaseBundle version resolution does not match ReleasePlan" + ) + snapshot = self.release_snapshot + if snapshot.decision_id != self.decision.decision_id: + raise ValueError("ReleaseBundle snapshot does not match decision") + if snapshot.change_set_id != self.change_set.change_set_id: + raise ValueError("ReleaseBundle snapshot does not match ChangeSet") + if snapshot.release_plan_id != self.release_plan.release_plan_id: + raise ValueError("ReleaseBundle snapshot does not match ReleasePlan") + if ( + snapshot.version_resolution_id + != self.version_resolution.version_resolution_id + ): + raise ValueError( + "ReleaseBundle snapshot does not match VersionResolution" + ) + expected = compute_release_bundle_digest( + decision=self.decision, + change_set=self.change_set, + release_plan=self.release_plan, + version_resolution=self.version_resolution, + release_snapshot=self.release_snapshot, + bundle_version=self.bundle_version, + ) + if self.bundle_digest != expected: + raise ValueError("ReleaseBundle digest does not match content") + return self + + +def build_release_bundle( + *, + decision: GovernanceDecision, + change_set: ChangeSet, + release_plan: ReleasePlan, + version_resolution: VersionResolution, + release_snapshot: ReleaseSnapshot, +) -> ReleaseBundle: + """Build the exact target-neutral release artifact reviewed by CI/CD.""" + digest = compute_release_bundle_digest( + decision=decision, + change_set=change_set, + release_plan=release_plan, + version_resolution=version_resolution, + release_snapshot=release_snapshot, + ) + return ReleaseBundle( + bundle_digest=digest, + decision=decision, + change_set=change_set, + release_plan=release_plan, + version_resolution=version_resolution, + release_snapshot=release_snapshot, + ) + + +def compute_release_bundle_digest( + *, + decision: GovernanceDecision, + change_set: ChangeSet, + release_plan: ReleasePlan, + version_resolution: VersionResolution, + release_snapshot: ReleaseSnapshot, + bundle_version: str = "1", +) -> str: + """Return the immutable digest for one target-neutral release bundle.""" + payload = { + "bundle_version": bundle_version, + "decision": decision.model_dump(mode="json"), + "change_set": change_set.model_dump(mode="json"), + "release_plan": release_plan.model_dump(mode="json"), + "version_resolution": version_resolution.model_dump(mode="json"), + "release_snapshot": release_snapshot.model_dump(mode="json"), + } + encoded = canonical_compact_json(payload).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" From 343b9d4340f79e76282ce71ecccdafca89c4fe37 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:36:53 +1000 Subject: [PATCH 177/616] refactor(release): persist release from target-neutral bundle --- .../services/contract_release_history.py | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/semapact/application/services/contract_release_history.py b/semapact/application/services/contract_release_history.py index bedc882d..be8a9ca7 100644 --- a/semapact/application/services/contract_release_history.py +++ b/semapact/application/services/contract_release_history.py @@ -3,45 +3,37 @@ from __future__ import annotations from semapact.approval import ApprovalRecord -from semapact.application.models.deployment_workflow import DeploymentBundle +from semapact.application.models.release import ReleaseBundle from semapact.governance import DecisionResult, GovernanceOperation from semapact.history import ContractReleaseHistoryRepository, ContractReleaseRecord from semapact.history.integrity import compute_contract_release_record_id class ContractReleaseHistoryService: - """Persist one formal release fact without coupling release to runtime telemetry.""" + """Persist one target-neutral formal release fact.""" def __init__(self, releases: ContractReleaseHistoryRepository) -> None: self._releases = releases def record_release( self, - bundle: DeploymentBundle, + bundle: ReleaseBundle, *, approval: ApprovalRecord | None, ) -> ContractReleaseRecord: - if not bundle.release: - raise ValueError("Non-release bundle cannot create contract release history") - if ( - bundle.release_plan is None - or bundle.version_resolution is None - or bundle.release_snapshot is None - ): - raise ValueError("Release bundle is missing release planning artifacts") - + """Persist one finalized release, idempotently, after exact review validation.""" if bundle.decision.decision is DecisionResult.REVIEW: if approval is None: raise ValueError("REVIEW release history requires exact approval") - if approval.operation is not GovernanceOperation.DEPLOY: - raise ValueError("Release approval must be DEPLOY-scoped") - if approval.scope_reference != bundle.deployment_plan.deployment_plan_id: + if approval.operation is not GovernanceOperation.PUBLISH: + raise ValueError("Release approval must be PUBLISH-scoped") + if approval.scope_reference != bundle.release_snapshot.release_snapshot_id: raise ValueError( - "Release approval is not scoped to exact DeploymentPlan" + "Release approval is not scoped to exact ReleaseSnapshot" ) if bundle.bundle_digest not in approval.evidence_references: raise ValueError( - "Release approval does not reference exact bundle digest" + "Release approval does not reference exact ReleaseBundle digest" ) elif approval is not None: raise ValueError("ALLOW release does not require ApprovalRecord") From c82ddc941fa19367f88587298a187774625a2b29 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:37:12 +1000 Subject: [PATCH 178/616] feat(release): add target-neutral assess and finalize workflow --- .../application/services/release_workflow.py | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 semapact/application/services/release_workflow.py diff --git a/semapact/application/services/release_workflow.py b/semapact/application/services/release_workflow.py new file mode 100644 index 00000000..07a56ba2 --- /dev/null +++ b/semapact/application/services/release_workflow.py @@ -0,0 +1,137 @@ +"""Application workflow for target-neutral formal contract releases.""" + +from __future__ import annotations + +from datetime import datetime + +from open_data_contract_standard.model import OpenDataContractStandard + +from semapact.approval import ApprovalRecord, build_approval_record +from semapact.approval.evidence import project_review_authorization_evidence +from semapact.application.models.release import ReleaseBundle, build_release_bundle +from semapact.application.services.contract_release_history import ( + ContractReleaseHistoryService, +) +from semapact.application.services.release_planning import ReleasePlanningService +from semapact.contractops import ( + ReviewEvidenceAction, + authorize_contract_operation, + build_release_snapshot, +) +from semapact.exceptions import ContractOpsAuthorizationError, ValidationError +from semapact.governance import DecisionResult, GovernanceOperation +from semapact.history import ContractReleaseRecord + + +class ReleaseWorkflowService: + """Assess and finalize one formal contract release independently of deployment.""" + + def __init__( + self, + *, + planning_service: ReleasePlanningService | None = None, + ) -> None: + self._planning = planning_service or ReleasePlanningService() + + def assess( + self, + base_contract: OpenDataContractStandard, + candidate_contract: OpenDataContractStandard, + *, + effective_date: str, + base_revision_ref: str, + candidate_revision_ref: str, + authority_reference: str | None = None, + ) -> ReleaseBundle: + """Build one immutable target-neutral formal release bundle.""" + planning = self._planning.plan( + base_contract, + candidate_contract, + effective_date=effective_date, + base_revision_ref=base_revision_ref, + candidate_revision_ref=candidate_revision_ref, + authority_reference=authority_reference, + ) + snapshot = build_release_snapshot( + candidate_contract, + candidate_revision_ref=candidate_revision_ref, + decision=planning.decision, + change_set=planning.change_set, + release_plan=planning.release_plan, + version_resolution=planning.version_resolution, + ) + return build_release_bundle( + decision=planning.decision, + change_set=planning.change_set, + release_plan=planning.release_plan, + version_resolution=planning.version_resolution, + release_snapshot=snapshot, + ) + + def approve( + self, + bundle: ReleaseBundle, + *, + actor_reference: str, + recorded_at: datetime, + comment: str | None = None, + ) -> ApprovalRecord: + """Create explicit approval evidence for custom/manual REVIEW workflows.""" + if bundle.decision.decision is not DecisionResult.REVIEW: + raise ValidationError( + "Explicit release approval is only required for REVIEW releases" + ) + return build_approval_record( + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=bundle.version_resolution.version_resolution_id, + operation=GovernanceOperation.PUBLISH, + action=ReviewEvidenceAction.APPROVE, + actor_reference=actor_reference, + recorded_at=recorded_at, + scope_reference=bundle.release_snapshot.release_snapshot_id, + comment=comment, + evidence_references=(bundle.bundle_digest,), + ) + + def finalize( + self, + bundle: ReleaseBundle, + *, + release_history: ContractReleaseHistoryService, + approval: ApprovalRecord | None = None, + ) -> ContractReleaseRecord: + """Authorize and persist one formal release exactly once.""" + evidence = None + if bundle.decision.decision is DecisionResult.REVIEW: + if approval is None: + raise ContractOpsAuthorizationError( + "Formal release requires approval for REVIEW" + ) + if approval.scope_reference != bundle.release_snapshot.release_snapshot_id: + raise ValidationError( + "ApprovalRecord is not scoped to the exact ReleaseSnapshot" + ) + if bundle.bundle_digest not in approval.evidence_references: + raise ValidationError( + "ApprovalRecord does not reference the exact ReleaseBundle digest" + ) + evidence = project_review_authorization_evidence(approval) + elif approval is not None: + raise ValidationError("ALLOW release does not consume an ApprovalRecord") + + authorization = authorize_contract_operation( + bundle.decision, + bundle.change_set, + bundle.release_plan, + bundle.version_resolution, + GovernanceOperation.PUBLISH, + evidence=evidence, + ) + if not authorization.allowed: + raise ContractOpsAuthorizationError( + "Formal release is not authorized: " + f"{authorization.reason.value}" + ) + return release_history.record_release(bundle, approval=approval) From 7b1afa28645b3dc55b3b7a43a00004e5c43b4b39 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:37:20 +1000 Subject: [PATCH 179/616] feat(release): resolve exact formal release approval from history --- .../application/services/release_approval.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 semapact/application/services/release_approval.py diff --git a/semapact/application/services/release_approval.py b/semapact/application/services/release_approval.py new file mode 100644 index 00000000..4e0a91a9 --- /dev/null +++ b/semapact/application/services/release_approval.py @@ -0,0 +1,36 @@ +"""Resolve exact formal-release approval from immutable approval history.""" + +from __future__ import annotations + +from semapact.approval import ApprovalRecord +from semapact.application.models.release import ReleaseBundle +from semapact.contractops import ReviewEvidenceAction +from semapact.governance import GovernanceOperation +from semapact.history import ApprovalHistoryRepository + + +class ReleaseApprovalResolver: + """Resolve exact PUBLISH approval for one immutable ReleaseBundle.""" + + def __init__(self, approvals: ApprovalHistoryRepository) -> None: + self._approvals = approvals + + def resolve(self, bundle: ReleaseBundle) -> ApprovalRecord | None: + records = self._approvals.list_approval_records_for_context( + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=bundle.version_resolution.version_resolution_id, + operation=GovernanceOperation.PUBLISH, + ) + scoped = tuple( + record + for record in records + if record.scope_reference == bundle.release_snapshot.release_snapshot_id + and bundle.bundle_digest in record.evidence_references + ) + if not scoped: + return None + if any(record.action is not ReviewEvidenceAction.APPROVE for record in scoped): + return None + return min(scoped, key=lambda record: record.approval_id) From 0d5be2962398fec5097e14e287fd3c38dbe0a6f0 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:37:29 +1000 Subject: [PATCH 180/616] feat(deployment): build desired state from finalized contract release --- semapact/deployment/source.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/semapact/deployment/source.py b/semapact/deployment/source.py index e66e5571..59707595 100644 --- a/semapact/deployment/source.py +++ b/semapact/deployment/source.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator from semapact.contractops import ReleaseSnapshot +from semapact.history.models import ContractReleaseRecord from semapact.utils.deterministic import canonical_compact_json, deterministic_uuid5 @@ -145,6 +146,37 @@ def build_release_deployment_source( ) +def build_contract_release_deployment_source( + release: ContractReleaseRecord, +) -> DeploymentSourceSnapshot: + """Project one finalized ContractReleaseRecord into deployment desired state.""" + if not isinstance(release, ContractReleaseRecord): + raise TypeError( + "release must be ContractReleaseRecord, " + f"got {type(release).__name__}" + ) + contract_json = release.released_contract_json + source_id = compute_deployment_source_id( + contract_id=release.contract_id, + revision_ref=release.revision_ref, + contract_version=release.contract_version, + contract_json=contract_json, + release=True, + release_id=release.contract_release_id, + release_plan_id=release.release_plan_id, + ) + return DeploymentSourceSnapshot( + source_snapshot_id=source_id, + contract_id=release.contract_id, + revision_ref=release.revision_ref, + contract_version=release.contract_version, + contract_json=contract_json, + release=True, + release_id=release.contract_release_id, + release_plan_id=release.release_plan_id, + ) + + def compute_deployment_source_id( *, contract_id: str, From c3532654000de5d00404cb3d683e7877376239f6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:37:41 +1000 Subject: [PATCH 181/616] feat(deployment): authorize finalized contract release deployment --- semapact/deployment/authorization.py | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index a64bd9a9..4c13d1b9 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -17,6 +17,7 @@ ) from semapact.deployment.source import DeploymentSourceSnapshot from semapact.governance import DecisionResult, GovernanceDecision +from semapact.history.models import ContractReleaseRecord from semapact.exceptions import ReleaseValidationError from semapact.governance.gate import GovernanceOperation @@ -150,6 +151,61 @@ def authorize_candidate_deployment( ) +def authorize_contract_release_deployment( + plan: DeploymentPlan, + source: DeploymentSourceSnapshot, + release: ContractReleaseRecord, +) -> DeploymentAuthorization: + """Authorize deployment from one already-finalized formal contract release.""" + if not plan.release or not source.release: + raise ReleaseValidationError( + "Finalized release deployment requires release plan/source" + ) + validate_deployment_plan_identity(plan) + if plan.source_snapshot_id != source.source_snapshot_id: + raise ReleaseValidationError( + "DeploymentPlan does not reference the supplied release source" + ) + if source.release_id != release.contract_release_id: + raise ReleaseValidationError( + "Deployment source does not reference the finalized ContractReleaseRecord" + ) + if source.contract_id != release.contract_id: + raise ReleaseValidationError( + "Deployment source and ContractReleaseRecord contract IDs do not match" + ) + if source.contract_version != release.contract_version: + raise ReleaseValidationError( + "Deployment source and ContractReleaseRecord versions do not match" + ) + if source.revision_ref != release.revision_ref: + raise ReleaseValidationError( + "Deployment source and ContractReleaseRecord revisions do not match" + ) + if plan.release_id != release.contract_release_id: + raise ReleaseValidationError( + "DeploymentPlan does not reference the finalized ContractReleaseRecord" + ) + + authorization_id = compute_deployment_authorization_id( + deployment_plan_id=plan.deployment_plan_id, + source_snapshot_id=source.source_snapshot_id, + authorization_kind="contract_release", + authorization_reference=release.contract_release_id, + allowed=True, + authorization_version="2", + ) + return DeploymentAuthorization( + deployment_authorization_id=authorization_id, + deployment_plan_id=plan.deployment_plan_id, + source_snapshot_id=source.source_snapshot_id, + allowed=True, + authorization_kind="contract_release", + authorization_reference=release.contract_release_id, + authorization_version="2", + ) + + def _validate_plan_release_context( plan: DeploymentPlan, release: ReleaseSnapshot | AppliedContractRelease, From 79c4748faf2edd9a672cebfc0408fc7a9c5e6975 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:38:10 +1000 Subject: [PATCH 182/616] refactor(deployment): consume finalized releases instead of release planning --- .../application/models/deployment_workflow.py | 184 +++++++----------- 1 file changed, 70 insertions(+), 114 deletions(-) diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py index ba8ae860..35f8b359 100644 --- a/semapact/application/models/deployment_workflow.py +++ b/semapact/application/models/deployment_workflow.py @@ -1,4 +1,4 @@ -"""Application artifacts for contract-first deployment workflows.""" +"""Application artifacts for target-specific deployment workflows.""" from __future__ import annotations @@ -7,18 +7,14 @@ from pydantic import BaseModel, ConfigDict, model_validator -from semapact.contractops import ( - ChangeSet, - ReleasePlan, - ReleaseSnapshot, - VersionResolution, -) +from semapact.contractops import ChangeSet from semapact.deployment import ( DeploymentPlan, DeploymentPreview, DeploymentSourceSnapshot, ) from semapact.governance import GovernanceDecision +from semapact.history.models import ContractReleaseRecord from semapact.reconciliation import ReconciliationResult, RuntimeDriftStatus from semapact.utils.deterministic import canonical_compact_json @@ -39,195 +35,155 @@ class DeploymentExecutionResult(BaseModel): class DeploymentBundle(BaseModel): - """Immutable CI-to-CD package for candidate or formal release deployment.""" + """Immutable target-specific CI-to-CD deployment package.""" model_config = ConfigDict(frozen=True, extra="forbid") bundle_digest: str release: bool - decision: GovernanceDecision - change_set: ChangeSet deployment_source: DeploymentSourceSnapshot deployment_plan: DeploymentPlan review_preview: DeploymentPreview - release_plan: ReleasePlan | None = None - version_resolution: VersionResolution | None = None - release_snapshot: ReleaseSnapshot | None = None - bundle_version: Literal["2"] = "2" + decision: GovernanceDecision | None = None + change_set: ChangeSet | None = None + contract_release: ContractReleaseRecord | None = None + bundle_version: Literal["3"] = "3" @model_validator(mode="after") def _validate_bundle_links(self) -> "DeploymentBundle": - if self.change_set.contract_id != self.decision.contract_id: - raise ValueError("DeploymentBundle decision/change-set contract mismatch") - if self.deployment_source.contract_id != self.decision.contract_id: - raise ValueError("DeploymentBundle source/decision contract mismatch") - if self.deployment_source.release is not self.release: - raise ValueError("DeploymentBundle source release mode mismatch") + source = self.deployment_source + plan = self.deployment_plan + + if source.release is not self.release or plan.release is not self.release: + raise ValueError("DeploymentBundle release mode mismatch") if self.release: - if ( - self.release_plan is None - or self.version_resolution is None - or self.release_snapshot is None - ): + if self.contract_release is None: raise ValueError( - "Release DeploymentBundle requires release planning artifacts" + "Release DeploymentBundle requires finalized ContractReleaseRecord" ) - if self.release_plan.change_set_id != self.change_set.change_set_id: + if self.decision is not None or self.change_set is not None: raise ValueError( - "DeploymentBundle release plan does not match ChangeSet" + "Release DeploymentBundle must not carry candidate governance artifacts" ) - if self.release_plan.decision_id != self.decision.decision_id: + release = self.contract_release + if source.release_id != release.contract_release_id: raise ValueError( - "DeploymentBundle release plan does not match decision" + "DeploymentBundle source does not reference ContractReleaseRecord" ) - if ( - self.version_resolution.release_plan_id - != self.release_plan.release_plan_id - ): + if source.contract_id != release.contract_id: raise ValueError( - "DeploymentBundle version resolution does not match ReleasePlan" + "DeploymentBundle source/release contract mismatch" ) - if self.release_snapshot.decision_id != self.decision.decision_id: - raise ValueError("DeploymentBundle snapshot does not match decision") - if self.release_snapshot.change_set_id != self.change_set.change_set_id: - raise ValueError("DeploymentBundle snapshot does not match ChangeSet") - if ( - self.release_snapshot.release_plan_id - != self.release_plan.release_plan_id - ): + if source.contract_version != release.contract_version: raise ValueError( - "DeploymentBundle snapshot does not match ReleasePlan" + "DeploymentBundle source/release version mismatch" ) - if ( - self.release_snapshot.version_resolution_id - != self.version_resolution.version_resolution_id - ): + if source.revision_ref != release.revision_ref: raise ValueError( - "DeploymentBundle snapshot does not match VersionResolution" + "DeploymentBundle source/release revision mismatch" ) - if self.deployment_source.release_id != self.release_snapshot.release_snapshot_id: + else: + if self.decision is None or self.change_set is None: raise ValueError( - "DeploymentBundle deployment source does not match ReleaseSnapshot" + "Candidate DeploymentBundle requires decision and ChangeSet" + ) + if self.contract_release is not None: + raise ValueError( + "Candidate DeploymentBundle cannot contain ContractReleaseRecord" + ) + if self.change_set.contract_id != self.decision.contract_id: + raise ValueError( + "DeploymentBundle decision/change-set contract mismatch" + ) + if source.contract_id != self.decision.contract_id: + raise ValueError( + "DeploymentBundle source/decision contract mismatch" ) - elif any( - item is not None - for item in ( - self.release_plan, - self.version_resolution, - self.release_snapshot, - ) - ): - raise ValueError( - "Non-release DeploymentBundle must not contain release artifacts" - ) - plan = self.deployment_plan - if plan.source_snapshot_id != self.deployment_source.source_snapshot_id: + if plan.source_snapshot_id != source.source_snapshot_id: raise ValueError( "DeploymentBundle plan does not match deployment source" ) - if plan.contract_id != self.deployment_source.contract_id: + if plan.contract_id != source.contract_id: raise ValueError("DeploymentBundle plan/source contract mismatch") - if plan.revision_ref != self.deployment_source.revision_ref: + if plan.revision_ref != source.revision_ref: raise ValueError("DeploymentBundle plan/source revision mismatch") - if plan.contract_version != self.deployment_source.contract_version: + if plan.contract_version != source.contract_version: raise ValueError("DeploymentBundle plan/source version mismatch") - if plan.release is not self.release: - raise ValueError("DeploymentBundle plan release mode mismatch") - if self.review_preview.deployment_plan_id != plan.deployment_plan_id: raise ValueError("DeploymentBundle preview does not match DeploymentPlan") expected = compute_deployment_bundle_digest( release=self.release, + deployment_source=source, + deployment_plan=plan, + review_preview=self.review_preview, decision=self.decision, change_set=self.change_set, - deployment_source=self.deployment_source, - deployment_plan=self.deployment_plan, - review_preview=self.review_preview, - release_plan=self.release_plan, - version_resolution=self.version_resolution, - release_snapshot=self.release_snapshot, + contract_release=self.contract_release, bundle_version=self.bundle_version, ) if self.bundle_digest != expected: raise ValueError("DeploymentBundle digest does not match content") return self + def build_deployment_bundle( *, release: bool, - decision: GovernanceDecision, - change_set: ChangeSet, deployment_source: DeploymentSourceSnapshot, deployment_plan: DeploymentPlan, review_preview: DeploymentPreview, - release_plan: ReleasePlan | None = None, - version_resolution: VersionResolution | None = None, - release_snapshot: ReleaseSnapshot | None = None, + decision: GovernanceDecision | None = None, + change_set: ChangeSet | None = None, + contract_release: ContractReleaseRecord | None = None, ) -> DeploymentBundle: - """Package exact CI material without introducing another authority artifact.""" + """Package exact target-specific deployment material without release planning.""" digest = compute_deployment_bundle_digest( release=release, - decision=decision, - change_set=change_set, deployment_source=deployment_source, deployment_plan=deployment_plan, review_preview=review_preview, - release_plan=release_plan, - version_resolution=version_resolution, - release_snapshot=release_snapshot, + decision=decision, + change_set=change_set, + contract_release=contract_release, ) return DeploymentBundle( bundle_digest=digest, release=release, - decision=decision, - change_set=change_set, deployment_source=deployment_source, deployment_plan=deployment_plan, review_preview=review_preview, - release_plan=release_plan, - version_resolution=version_resolution, - release_snapshot=release_snapshot, + decision=decision, + change_set=change_set, + contract_release=contract_release, ) def compute_deployment_bundle_digest( *, release: bool, - decision: GovernanceDecision, - change_set: ChangeSet, deployment_source: DeploymentSourceSnapshot, deployment_plan: DeploymentPlan, review_preview: DeploymentPreview, - release_plan: ReleasePlan | None = None, - version_resolution: VersionResolution | None = None, - release_snapshot: ReleaseSnapshot | None = None, - bundle_version: str = "2", + decision: GovernanceDecision | None = None, + change_set: ChangeSet | None = None, + contract_release: ContractReleaseRecord | None = None, + bundle_version: str = "3", ) -> str: - """Return the immutable CI artifact digest for candidate or release deployment.""" + """Return the immutable digest for one target-specific deployment bundle.""" payload = { "bundle_version": bundle_version, "release": release, - "decision": decision.model_dump(mode="json"), - "change_set": change_set.model_dump(mode="json"), "deployment_source": deployment_source.model_dump(mode="json"), "deployment_plan": deployment_plan.model_dump(mode="json"), "review_preview": review_preview.model_dump(mode="json"), - "release_plan": ( - release_plan.model_dump(mode="json") - if release_plan is not None - else None - ), - "version_resolution": ( - version_resolution.model_dump(mode="json") - if version_resolution is not None - else None - ), - "release_snapshot": ( - release_snapshot.model_dump(mode="json") - if release_snapshot is not None + "decision": decision.model_dump(mode="json") if decision is not None else None, + "change_set": change_set.model_dump(mode="json") if change_set is not None else None, + "contract_release": ( + contract_release.model_dump(mode="json") + if contract_release is not None else None ), } From 577853657e3fd6eb1459e2ba5b177de1187c4f7a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:38:33 +1000 Subject: [PATCH 183/616] refactor(deployment): deploy only candidate or finalized release state --- .../services/deployment_workflow.py | 204 ++++-------------- 1 file changed, 45 insertions(+), 159 deletions(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index 4afe4017..93cc2117 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -1,4 +1,4 @@ -"""Canonical contract-first deployment workflow orchestration.""" +"""Canonical target-specific deployment workflow orchestration.""" from __future__ import annotations @@ -11,35 +11,21 @@ DeploymentExecutionResult, build_deployment_bundle, ) -from semapact.application.services.contract_release_history import ( - ContractReleaseHistoryService, -) from semapact.application.services.deployment import DeploymentService from semapact.application.services.governance import GovernanceService -from semapact.application.services.release_planning import ReleasePlanningService -from semapact.approval import ( - ApprovalRecord, - build_approval_record, - project_review_authorization_evidence, -) -from semapact.contractops import ( - ReviewEvidenceAction, - authorize_contract_operation, - build_release_snapshot, -) from semapact.deployment import ( DeploymentAdapter, DeploymentTarget, RuntimeReleaseMetadata, RuntimeReleaseMetadataProjector, authorize_candidate_deployment, - authorize_deployment, + authorize_contract_release_deployment, build_candidate_deployment_source, - build_release_deployment_source, + build_contract_release_deployment_source, ) -from semapact.exceptions import ContractOpsAuthorizationError, ValidationError -from semapact.governance import DecisionResult, GovernanceOperation +from semapact.exceptions import ContractOpsAuthorizationError from semapact.history import ( + ContractReleaseRecord, OperationalHistorySink, build_operational_deployment_event, ) @@ -47,16 +33,14 @@ class DeploymentWorkflowService: - """Compose release planning and runtime assessment without inventing authority.""" + """Assess candidate/finalized-release desired state and converge one runtime target.""" def __init__( self, *, - release_planning_service: ReleasePlanningService | None = None, governance_service: GovernanceService | None = None, deployment_service: DeploymentService | None = None, ) -> None: - self._release_planning = release_planning_service or ReleasePlanningService() self._governance = governance_service or GovernanceService() self._deployment = deployment_service or DeploymentService() @@ -70,93 +54,47 @@ def assess( candidate_revision_ref: str, target: DeploymentTarget, adapter: DeploymentAdapter, - release: bool = False, - authority_reference: str | None = None, ) -> DeploymentBundle: - """Build an immutable candidate or formal-release CI handoff bundle.""" - if release: - planned = self._release_planning.plan( - base_contract, - candidate_contract, - effective_date=effective_date, - base_revision_ref=base_revision_ref, - candidate_revision_ref=candidate_revision_ref, - authority_reference=authority_reference, - ) - snapshot = build_release_snapshot( - candidate_contract, - candidate_revision_ref=candidate_revision_ref, - decision=planned.decision, - change_set=planned.change_set, - release_plan=planned.release_plan, - version_resolution=planned.version_resolution, - ) - source = build_release_deployment_source(snapshot) - decision = planned.decision - change_set = planned.change_set - release_plan = planned.release_plan - version_resolution = planned.version_resolution - else: - proposal = self._governance.evaluate_proposal( - base_contract, - candidate_contract, - effective_date=effective_date, - base_revision_ref=base_revision_ref, - candidate_revision_ref=candidate_revision_ref, - ) - source = build_candidate_deployment_source( - candidate_contract, - revision_ref=candidate_revision_ref, - ) - decision = proposal.decision - change_set = proposal.change_set - release_plan = None - version_resolution = None - snapshot = None - + """Build an immutable non-release candidate deployment bundle.""" + proposal = self._governance.evaluate_proposal( + base_contract, + candidate_contract, + effective_date=effective_date, + base_revision_ref=base_revision_ref, + candidate_revision_ref=candidate_revision_ref, + ) + source = build_candidate_deployment_source( + candidate_contract, + revision_ref=candidate_revision_ref, + ) plan = self._deployment.plan(source, target) preview = self._deployment.preview(plan, adapter=adapter) return build_deployment_bundle( - release=release, - decision=decision, - change_set=change_set, + release=False, + decision=proposal.decision, + change_set=proposal.change_set, deployment_source=source, deployment_plan=plan, review_preview=preview, - release_plan=release_plan, - version_resolution=version_resolution, - release_snapshot=snapshot, ) - def approve( + def assess_release( self, - bundle: DeploymentBundle, + release: ContractReleaseRecord, *, - actor_reference: str, - recorded_at: datetime, - comment: str | None = None, - ) -> ApprovalRecord: - """Record an explicit human DEPLOY approval for one exact bundle.""" - if not bundle.release: - raise ValidationError( - "Non-release deployment does not create approval records" - ) - if bundle.decision.decision is not DecisionResult.REVIEW: - raise ValidationError( - "Explicit deployment approval is only required for REVIEW releases" - ) - return build_approval_record( - decision_id=bundle.decision.decision_id, - change_set_id=bundle.change_set.change_set_id, - release_plan_id=bundle.release_plan.release_plan_id, - version_resolution_id=bundle.version_resolution.version_resolution_id, - operation=GovernanceOperation.DEPLOY, - action=ReviewEvidenceAction.APPROVE, - actor_reference=actor_reference, - recorded_at=recorded_at, - scope_reference=bundle.deployment_plan.deployment_plan_id, - comment=comment, - evidence_references=(bundle.bundle_digest,), + target: DeploymentTarget, + adapter: DeploymentAdapter, + ) -> DeploymentBundle: + """Build one target-specific bundle from an already-finalized release.""" + source = build_contract_release_deployment_source(release) + plan = self._deployment.plan(source, target) + preview = self._deployment.preview(plan, adapter=adapter) + return build_deployment_bundle( + release=True, + contract_release=release, + deployment_source=source, + deployment_plan=plan, + review_preview=preview, ) def deploy( @@ -164,65 +102,25 @@ def deploy( bundle: DeploymentBundle, *, adapter: DeploymentAdapter, - approval: ApprovalRecord | None = None, - release_history: ContractReleaseHistoryService | None = None, operational_history: OperationalHistorySink | None = None, metadata_projector: RuntimeReleaseMetadataProjector | None = None, ) -> DeploymentExecutionResult: - """Consume one exact bundle, authorize, deploy, verify, and persist configured facts.""" + """Consume one exact target bundle, deploy from fresh runtime evidence, and verify.""" if not isinstance(bundle, DeploymentBundle): raise TypeError( f"bundle must be DeploymentBundle, got {type(bundle).__name__}" ) - release_record = None - deployment_authorization = None - - contract_authorization = None if bundle.release: - if ( - bundle.release_plan is None - or bundle.version_resolution is None - or bundle.release_snapshot is None - ): - raise ValidationError( - "Release deployment bundle is missing release artifacts" - ) - evidence = None - if bundle.decision.decision is DecisionResult.REVIEW: - if approval is None: - raise ContractOpsAuthorizationError( - "Release deployment requires approval for REVIEW" - ) - if bundle.bundle_digest not in approval.evidence_references: - raise ValidationError( - "ApprovalRecord does not reference the exact DeploymentBundle digest" - ) - evidence = project_review_authorization_evidence(approval) - - contract_authorization = authorize_contract_operation( - bundle.decision, - bundle.change_set, - bundle.release_plan, - bundle.version_resolution, - GovernanceOperation.DEPLOY, - evidence=evidence, - ) - if not contract_authorization.allowed: - raise ContractOpsAuthorizationError( - "Deployment is not authorized: " - f"{contract_authorization.reason.value}" - ) - deployment_authorization = authorize_deployment( + assert bundle.contract_release is not None + deployment_authorization = authorize_contract_release_deployment( bundle.deployment_plan, - bundle.release_snapshot, - contract_authorization, + bundle.deployment_source, + bundle.contract_release, ) + release_record = bundle.contract_release else: - if approval is not None: - raise ValidationError( - "Non-release deployment must not consume an ApprovalRecord" - ) + assert bundle.decision is not None deployment_authorization = authorize_candidate_deployment( bundle.deployment_plan, bundle.deployment_source, @@ -232,18 +130,8 @@ def deploy( raise ContractOpsAuthorizationError( "Candidate deployment is blocked by governance" ) + release_record = None - if bundle.release: - if release_history is None: - raise ValidationError( - "Formal release deployment requires a release history repository" - ) - release_record = release_history.record_release( - bundle, - approval=approval, - ) - - # CI-time review preview is evidence only. CD always derives a fresh preview. started_at = datetime.now(timezone.utc) fresh_preview = None reconciliation = None @@ -266,9 +154,8 @@ def deploy( status = classify_reconciliation_status(reconciliation) if ( - bundle.release + release_record is not None and status is RuntimeDriftStatus.IN_SYNC - and release_record is not None and metadata_projector is not None ): metadata_projector.project_release_metadata( @@ -357,4 +244,3 @@ def deploy( ) ) return result - From 79387d4e309b558d6d4e170f7fffab7a56caac00 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:38:40 +1000 Subject: [PATCH 184/616] refactor(deployment): export finalized release seams --- semapact/deployment/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index fcf54c1e..00a37628 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -7,6 +7,7 @@ ) from semapact.deployment.authorization import ( authorize_candidate_deployment, + authorize_contract_release_deployment, authorize_deployment, ) from semapact.deployment.compilers import TransitionCompiler @@ -29,6 +30,7 @@ from semapact.deployment.source import ( DeploymentSourceSnapshot, build_candidate_deployment_source, + build_contract_release_deployment_source, build_release_deployment_source, ) from semapact.deployment.providers import ( @@ -60,11 +62,13 @@ "TransitionCompiler", "SchemaTransitionPlanner", "authorize_candidate_deployment", + "authorize_contract_release_deployment", "authorize_deployment", "build_deployment_actions", "build_deployment_plan", "build_deployment_plan_from_source", "build_candidate_deployment_source", + "build_contract_release_deployment_source", "build_release_deployment_source", "DeploymentSourceSnapshot", "verify_deployment_convergence", From a4866701246a40a8fff37e766a48ca5ca3b90037 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:39:11 +1000 Subject: [PATCH 185/616] feat(cli): add target-neutral release assess approve finalize --- semapact/interfaces/commands/release_cmd.py | 102 ++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index dd70b21e..35c41e44 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -11,6 +11,108 @@ ) + +def run_release_assess(args: argparse.Namespace) -> dict[str, Any]: + """Build one immutable target-neutral ReleaseBundle.""" + from semapact.application.services.release_workflow import ReleaseWorkflowService + from semapact.core.loader import ContractLoader + + loader = ContractLoader(runtime_context=args.runtime_context) + base_contract = loader.load(args.base) + candidate_contract = loader.load(args.candidate) + bundle = ReleaseWorkflowService().assess( + base_contract, + candidate_contract, + effective_date=args.effective_date, + base_revision_ref=args.base_revision_ref, + candidate_revision_ref=args.candidate_revision_ref, + authority_reference=args.authority_reference, + ) + if args.bundle_out: + _write_model_artifact(args.bundle_out, bundle) + return bundle.model_dump(mode="json") + + +def run_release_approve(args: argparse.Namespace) -> dict[str, Any]: + """Record explicit REVIEW approval for one exact ReleaseBundle.""" + from semapact.application.models.release import ReleaseBundle + from semapact.application.services.release_workflow import ReleaseWorkflowService + from semapact.interfaces.parsing import parse_iso_timestamp + from semapact.platforms.git import GitWorkingTreeHistoryRepository + + bundle = _load_model(args.bundle, ReleaseBundle) + approval = ReleaseWorkflowService().approve( + bundle, + actor_reference=args.actor_reference, + recorded_at=parse_iso_timestamp(args.recorded_at), + comment=args.comment, + ) + GitWorkingTreeHistoryRepository(args.repository_root).put_approval_record(approval) + if args.approval_out: + _write_model_artifact(args.approval_out, approval) + return approval.model_dump(mode="json") + + +def run_release_finalize(args: argparse.Namespace) -> dict[str, Any]: + """Finalize one exact release, persist ledger fact, and materialize versioned ODCS.""" + from semapact.application.models.release import ReleaseBundle + from semapact.application.services.contract_release_history import ( + ContractReleaseHistoryService, + ) + from semapact.application.services.release_approval import ReleaseApprovalResolver + from semapact.application.services.release_workflow import ReleaseWorkflowService + from semapact.governance import DecisionResult + from semapact.platforms.git import GitWorkingTreeHistoryRepository + from semapact.utils.yaml_utils import dump_yaml + + bundle = _load_model(args.bundle, ReleaseBundle) + repository = GitWorkingTreeHistoryRepository(args.repository_root) + approval = None + if bundle.decision.decision is DecisionResult.REVIEW: + approval = ReleaseApprovalResolver(repository).resolve(bundle) + + record = ReleaseWorkflowService().finalize( + bundle, + release_history=ContractReleaseHistoryService(repository), + approval=approval, + ) + output_path = dump_yaml(bundle.release_snapshot.to_contract(), args.output_contract) + return { + "contractReleaseId": record.contract_release_id, + "contractId": record.contract_id, + "contractVersion": record.contract_version, + "releaseBundleDigest": bundle.bundle_digest, + "outputContract": str(output_path), + } + + +def _load_model(path: str, model_type): + from pydantic import ValidationError as PydanticValidationError + + from semapact.exceptions import ValidationError + + try: + return model_type.model_validate_json(Path(path).read_text(encoding="utf-8")) + except (OSError, PydanticValidationError) as exc: + raise ValidationError( + f"Invalid {model_type.__name__} artifact '{path}': {exc}" + ) from exc + + +def _write_model_artifact(path: str, model) -> None: + artifact_path = Path(path) + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text( + json.dumps( + model.model_dump(mode="json"), + indent=2, + ensure_ascii=False, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + def run_release_classify(args: argparse.Namespace) -> dict[str, Any]: """Analyze one change without creating canonical release artifacts.""" from dataclasses import asdict From fd5a93606d59ba0b48bcff3c13a027572666d999 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:39:44 +1000 Subject: [PATCH 186/616] refactor(cli): separate formal release from deployment lifecycle --- semapact/interfaces/cli.py | 160 ++++++++++++++++++++++--------------- 1 file changed, 94 insertions(+), 66 deletions(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index 1d5897cd..6394f6aa 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -275,6 +275,63 @@ def _build_parser() -> argparse.ArgumentParser: dest="release_command", required=True ) + release_assess_parser = release_subparsers.add_parser( + "assess", + help="Build one immutable target-neutral formal ReleaseBundle", + ) + release_assess_parser.add_argument("--base", required=True) + release_assess_parser.add_argument("--candidate", required=True) + release_assess_parser.add_argument("--base-revision-ref", required=True) + release_assess_parser.add_argument("--candidate-revision-ref", required=True) + release_assess_parser.add_argument( + "--authority-reference", + help="Explicit Git release reference when release.versionAuthority=git", + ) + release_assess_parser.add_argument("--runtime-context", default="auto") + release_assess_parser.add_argument( + "--bundle-out", + help="Write the immutable ReleaseBundle JSON artifact to this path", + ) + _add_effective_date_argument(release_assess_parser) + + release_approve_parser = release_subparsers.add_parser( + "approve", + help="Record explicit REVIEW approval for one exact ReleaseBundle", + ) + release_approve_parser.add_argument("--bundle", required=True) + release_approve_parser.add_argument("--actor-reference", required=True) + release_approve_parser.add_argument( + "--recorded-at", + required=True, + help="Approval timestamp as timezone-aware ISO-8601", + ) + release_approve_parser.add_argument("--comment") + release_approve_parser.add_argument( + "--repository-root", + default=".", + help="Repository root containing the Git governance ledger", + ) + release_approve_parser.add_argument( + "--approval-out", + help="Optional standalone ApprovalRecord JSON output", + ) + + release_finalize_parser = release_subparsers.add_parser( + "finalize", + help="Finalize one ReleaseBundle, record it, and materialize the versioned contract", + ) + release_finalize_parser.add_argument("--bundle", required=True) + release_finalize_parser.add_argument( + "--output-contract", + required=True, + help="Write the exact released/versioned ODCS contract to this path", + ) + release_finalize_parser.add_argument( + "--repository-root", + default=".", + help="Repository root containing the Git governance ledger", + ) + release_classify_parser = release_subparsers.add_parser( "classify", help="Analyze the required version bump without creating release artifacts", @@ -443,7 +500,7 @@ def _build_parser() -> argparse.ArgumentParser: deployment_parser = subparsers.add_parser( "deployment", - help="Assess, approve, and deploy immutable governed runtime bundles", + help="Assess and deploy immutable target-specific runtime bundles", ) deployment_subparsers = deployment_parser.add_subparsers( dest="deployment_command", required=True @@ -451,41 +508,41 @@ def _build_parser() -> argparse.ArgumentParser: deployment_assess_parser = deployment_subparsers.add_parser( "assess", - help="Assess base/candidate contract change against fresh runtime without mutation", + help=( + "Assess either a base/candidate change or an already-finalized release " + "against one fresh runtime target" + ), ) - deployment_assess_parser.add_argument("--base", required=True) - deployment_assess_parser.add_argument("--candidate", required=True) - deployment_assess_parser.add_argument("--base-revision-ref", required=True) - deployment_assess_parser.add_argument("--candidate-revision-ref", required=True) + deployment_assess_parser.add_argument("--base") + deployment_assess_parser.add_argument("--candidate") + deployment_assess_parser.add_argument("--base-revision-ref") + deployment_assess_parser.add_argument("--candidate-revision-ref") deployment_assess_parser.add_argument( - "--authority-reference", - help="Explicit Git release reference when release.versionAuthority=git", + "--release-id", + help="Finalized ContractReleaseRecord ID to deploy instead of a candidate", + ) + deployment_assess_parser.add_argument( + "--repository-root", + default=".", + help="Repository root containing finalized release history", ) deployment_assess_parser.add_argument( "--server", - help="Contract server identifier when the candidate defines multiple servers", + help="Contract server identifier when the deployment source defines multiple servers", ) deployment_assess_parser.add_argument( "--platform", - help="Fallback runtime provider when the candidate defines no servers", + help="Fallback runtime provider when the deployment source defines no servers", ) deployment_assess_parser.add_argument( "--runtime", - help="Fallback provider-local runtime target when the candidate defines no servers", + help="Fallback provider-local runtime target when the deployment source defines no servers", ) deployment_assess_parser.add_argument( "--source-reference", - help="Fallback stable runtime source identity when the candidate defines no server host", + help="Fallback stable runtime source identity when the source defines no server host", ) deployment_assess_parser.add_argument("--runtime-context", default="auto") - deployment_assess_parser.add_argument( - "--release", - action="store_true", - help=( - "Create a formal contract release: resolve a new semantic version, " - "require release approval when governance is REVIEW, and record release history" - ), - ) deployment_assess_parser.add_argument( "--bundle-out", help="Write the immutable DeploymentBundle JSON artifact to this path", @@ -496,54 +553,13 @@ def _build_parser() -> argparse.ArgumentParser: default="text", help="Output format (default: text)", ) - _add_effective_date_argument(deployment_assess_parser) - - deployment_approve_parser = deployment_subparsers.add_parser( - "approve", - help=( - "Create an explicit DEPLOY ApprovalRecord for a custom/manual workflow; " - "standard CD can resolve Git-backed approval history automatically" - ), - ) - deployment_approve_parser.add_argument("--bundle", required=True) - deployment_approve_parser.add_argument("--actor-reference", required=True) - deployment_approve_parser.add_argument( - "--recorded-at", - required=True, - help="Approval timestamp as timezone-aware ISO-8601", - ) - deployment_approve_parser.add_argument("--comment") - deployment_approve_parser.add_argument( - "--approval-out", - help="Write the exact ApprovalRecord JSON artifact to this path", - ) - deployment_approve_parser.add_argument( - "--output", - choices=["text", "json"], - default="text", - help="Output format (default: text)", - ) + _add_effective_date_argument(deployment_assess_parser, required=False) deployment_deploy_parser = deployment_subparsers.add_parser( "deploy", help="Consume an immutable DeploymentBundle, execute against fresh runtime, and verify", ) deployment_deploy_parser.add_argument("--bundle", required=True) - deployment_deploy_parser.add_argument( - "--approval", - help=( - "Optional ApprovalRecord JSON. When omitted for REVIEW, SemaPact " - "resolves an exact matching approval from Git-backed history." - ), - ) - deployment_deploy_parser.add_argument( - "--repository-root", - default=".", - help=( - "Repository root containing .semapact/history approval records " - "(default: current directory)" - ), - ) deployment_deploy_parser.add_argument( "--warehouse-id", help=( @@ -685,15 +701,12 @@ def main() -> int: if args.command == "deployment": from semapact.interfaces.commands.deployment_cmd import ( - run_deployment_approve, run_deployment_assess, run_deployment_deploy, ) from semapact.interfaces.outcomes import exit_code_from_outcome - if args.deployment_command == "approve": - result = run_deployment_approve(args) - elif args.deployment_command == "assess": + if args.deployment_command == "assess": result = run_deployment_assess(args) elif args.deployment_command == "deploy": result = run_deployment_deploy(args) @@ -704,14 +717,29 @@ def main() -> int: if args.command == "release": from semapact.interfaces.commands.release_cmd import ( + run_release_approve, + run_release_assess, run_release_build_manifest, run_release_classify, run_release_classify_repo, run_release_create_pr, run_release_create_prs, + run_release_finalize, run_release_plan, run_release_prepare, ) + if args.release_command == "assess": + payload = run_release_assess(args) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + if args.release_command == "approve": + payload = run_release_approve(args) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + if args.release_command == "finalize": + payload = run_release_finalize(args) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 if args.release_command == "classify": payload = run_release_classify(args) print(json.dumps(payload, indent=2, sort_keys=True)) From 52ae388a29eb09c7289cb122769abcf5c9d4ffcf Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:40:13 +1000 Subject: [PATCH 187/616] refactor(cli): deploy candidates or finalized releases only --- .../interfaces/commands/deployment_cmd.py | 273 +++++++++--------- 1 file changed, 129 insertions(+), 144 deletions(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 3e296a22..a2cfa875 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -1,4 +1,4 @@ -"""CLI adapter for the bundle-driven deployment workflow.""" +"""CLI adapter for target-specific deployment workflows.""" from __future__ import annotations @@ -12,7 +12,6 @@ from pydantic import BaseModel from pydantic import ValidationError as PydanticValidationError -from semapact.approval import ApprovalRecord from semapact.application.models.deployment_workflow import DeploymentBundle from semapact.deployment import DeploymentTarget from semapact.exceptions import ValidationError @@ -21,7 +20,6 @@ outcome_from_gate_result, outcome_from_reconciliation_status, ) -from semapact.interfaces.parsing import parse_iso_timestamp _ModelT = TypeVar("_ModelT", bound=BaseModel) @@ -29,101 +27,107 @@ @dataclass(frozen=True) class DeploymentCommandResult: - """Rendered CLI output plus its existing semantic process outcome.""" + """Rendered CLI output plus its semantic process outcome.""" output: str outcome: ProcessOutcome def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: - """Build one immutable CI/manual deployment bundle without runtime mutation.""" + """Build one target-specific candidate or finalized-release deployment bundle.""" + from open_data_contract_standard.model import OpenDataContractStandard + from semapact.application.services.deployment_workflow import ( DeploymentWorkflowService, ) from semapact.core.loader import ContractLoader from semapact.governance import GovernanceOperation, evaluate_governance_gate + from semapact.platforms.git import GitWorkingTreeHistoryRepository from semapact.platforms.runtime_registry import ( create_deployment_adapter, resolve_runtime_location, ) + service = DeploymentWorkflowService() loader = ContractLoader(runtime_context=args.runtime_context) - base_contract = loader.load(args.base) - candidate_contract = loader.load(args.candidate) - location = resolve_runtime_location( - candidate_contract, - server_name=args.server, - fallback_platform=args.platform, - fallback_runtime_target=args.runtime, - ) - source_reference = _assessment_source_reference( - location.contract_server, - args.source_reference, - ) - target = DeploymentTarget( - platform=location.platform, - runtime_target=location.runtime_target, - source_reference=source_reference, - server_name=location.server_name, - ) - adapter = create_deployment_adapter( - location.platform, - contract_server=location.contract_server, - ) - bundle = DeploymentWorkflowService().assess( - base_contract, - candidate_contract, - effective_date=args.effective_date, - base_revision_ref=args.base_revision_ref, - candidate_revision_ref=args.candidate_revision_ref, - authority_reference=args.authority_reference, - target=target, - adapter=adapter, - release=args.release, - ) + + if args.release_id: + _reject_candidate_args_for_release(args) + release = GitWorkingTreeHistoryRepository( + args.repository_root + ).get_contract_release(args.release_id) + source_contract = OpenDataContractStandard.model_validate_json( + release.released_contract_json + ) + location = resolve_runtime_location( + source_contract, + server_name=args.server, + fallback_platform=args.platform, + fallback_runtime_target=args.runtime, + ) + target = _deployment_target( + location, + source_reference=_assessment_source_reference( + location.contract_server, + args.source_reference, + ), + ) + adapter = create_deployment_adapter( + location.platform, + contract_server=location.contract_server, + ) + bundle = service.assess_release( + release, + target=target, + adapter=adapter, + ) + outcome = ProcessOutcome.SUCCESS + else: + _require_candidate_args(args) + base_contract = loader.load(args.base) + candidate_contract = loader.load(args.candidate) + location = resolve_runtime_location( + candidate_contract, + server_name=args.server, + fallback_platform=args.platform, + fallback_runtime_target=args.runtime, + ) + target = _deployment_target( + location, + source_reference=_assessment_source_reference( + location.contract_server, + args.source_reference, + ), + ) + adapter = create_deployment_adapter( + location.platform, + contract_server=location.contract_server, + ) + bundle = service.assess( + base_contract, + candidate_contract, + effective_date=args.effective_date, + base_revision_ref=args.base_revision_ref, + candidate_revision_ref=args.candidate_revision_ref, + target=target, + adapter=adapter, + ) + assert bundle.decision is not None + gate = evaluate_governance_gate( + bundle.decision, + GovernanceOperation.PROPOSE, + ) + outcome = outcome_from_gate_result(gate) + if args.bundle_out: _write_model_artifact(args.bundle_out, bundle) - gate = evaluate_governance_gate( - bundle.decision, - GovernanceOperation.PROPOSE, - ) rendered = ( _model_json(bundle) if args.output == "json" else _bundle_text(bundle, artifact_path=args.bundle_out) ) - return DeploymentCommandResult( - output=rendered, - outcome=outcome_from_gate_result(gate), - ) - - -def run_deployment_approve(args: argparse.Namespace) -> DeploymentCommandResult: - """Create one exact DEPLOY ApprovalRecord from a REVIEW DeploymentBundle.""" - from semapact.application.services.deployment_workflow import ( - DeploymentWorkflowService, - ) - - bundle = _load_model(args.bundle, DeploymentBundle) - approval = DeploymentWorkflowService().approve( - bundle, - actor_reference=args.actor_reference, - recorded_at=parse_iso_timestamp(args.recorded_at), - comment=args.comment, - ) - if args.approval_out: - _write_model_artifact(args.approval_out, approval) - - rendered = ( - _model_json(approval) - if args.output == "json" - else _approval_text(approval, artifact_path=args.approval_out) - ) - return DeploymentCommandResult( - output=rendered, - outcome=ProcessOutcome.SUCCESS, - ) + return DeploymentCommandResult(output=rendered, outcome=outcome) def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: @@ -134,11 +138,6 @@ def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: from semapact.platforms.runtime_registry import create_deployment_adapter bundle = _load_model(args.bundle, DeploymentBundle) - approval = _resolve_deployment_approval( - bundle, - approval_path=args.approval, - repository_root=args.repository_root, - ) execution_config = None if bundle.deployment_plan.target.platform == "databricks": @@ -159,20 +158,9 @@ def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: execution_config=execution_config, ) - from semapact.application.services.contract_release_history import ( - ContractReleaseHistoryService, - ) from semapact.deployment import RuntimeReleaseMetadataProjector from semapact.history import create_operational_history_sink - from semapact.platforms.git import GitWorkingTreeHistoryRepository - release_history = ( - ContractReleaseHistoryService( - GitWorkingTreeHistoryRepository(args.repository_root) - ) - if bundle.release - else None - ) operational_history = create_operational_history_sink( _resolve_operational_history_uri(args.operational_history) ) @@ -185,8 +173,6 @@ def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: result = DeploymentWorkflowService().deploy( bundle, adapter=adapter, - approval=approval, - release_history=release_history, operational_history=operational_history, metadata_projector=metadata_projector, ) @@ -201,6 +187,49 @@ def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: ) +def _require_candidate_args(args: argparse.Namespace) -> None: + required = { + "--base": args.base, + "--candidate": args.candidate, + "--base-revision-ref": args.base_revision_ref, + "--candidate-revision-ref": args.candidate_revision_ref, + "--effective-date": args.effective_date, + } + missing = [name for name, value in required.items() if not value] + if missing: + raise ValidationError( + "Candidate deployment assessment requires " + ", ".join(missing) + ) + + +def _reject_candidate_args_for_release(args: argparse.Namespace) -> None: + supplied = [ + name + for name, value in ( + ("--base", args.base), + ("--candidate", args.candidate), + ("--base-revision-ref", args.base_revision_ref), + ("--candidate-revision-ref", args.candidate_revision_ref), + ("--effective-date", args.effective_date), + ) + if value + ] + if supplied: + raise ValidationError( + "--release-id cannot be combined with candidate assessment arguments: " + + ", ".join(supplied) + ) + + +def _deployment_target(location, *, source_reference: str) -> DeploymentTarget: + return DeploymentTarget( + platform=location.platform, + runtime_target=location.runtime_target, + source_reference=source_reference, + server_name=location.server_name, + ) + + def _resolve_operational_history_uri(cli_override: str | None) -> str | None: """Resolve CLI override first, then typed project/global configuration.""" if cli_override is not None and cli_override.strip(): @@ -220,29 +249,6 @@ def _resolve_operational_history_uri(cli_override: str | None) -> str | None: ) from exc -def _resolve_deployment_approval( - bundle: DeploymentBundle, - *, - approval_path: str | None, - repository_root: str, -) -> ApprovalRecord | None: - if approval_path is not None: - return _load_model(approval_path, ApprovalRecord) - - from semapact.application.services.deployment_approval import ( - DeploymentApprovalResolver, - ) - from semapact.governance import DecisionResult - from semapact.platforms.git import GitWorkingTreeHistoryRepository - - if not bundle.release or bundle.decision.decision is not DecisionResult.REVIEW: - return None - - return DeploymentApprovalResolver( - GitWorkingTreeHistoryRepository(repository_root), - ).resolve(bundle) - - def _load_model(path: str, model_type: type[_ModelT]) -> _ModelT: try: raw = ( @@ -304,18 +310,20 @@ def _bundle_text( f"Contract: {bundle.deployment_source.contract_id}@" f"{bundle.deployment_source.contract_version}", f"Mode: {'release' if bundle.release else 'candidate'}", - f"Governance: {bundle.decision.decision.value}", - ( - f"Required bump: {bundle.decision.required_version_bump}" - if bundle.release - else "Required bump: not calculated" - ), f"Target: {plan.target.platform}/{plan.target.runtime_target}", f"Deployment plan: {plan.deployment_plan_id}", f"Bundle digest: {bundle.bundle_digest}", "Execution authority: none (CI/read-only bundle)", - "Review operations:", ] + if bundle.release: + assert bundle.contract_release is not None + lines.append( + f"Contract release: {bundle.contract_release.contract_release_id}" + ) + else: + assert bundle.decision is not None + lines.append(f"Governance: {bundle.decision.decision.value}") + lines.append("Review operations:") for operation in preview.operations: detail = f" - {operation.kind.value} {operation.governed_asset}" if operation.statement is not None: @@ -334,7 +342,6 @@ def _bundle_text( return "\n".join(lines) - def _deployment_result_text(result) -> str: lines = [ f"Bundle digest: {result.bundle_digest}", @@ -361,25 +368,3 @@ def _deployment_result_text(result) -> str: if not result.fresh_preview.operations: lines.append(" - none") return "\n".join(lines) - - - -def _approval_text( - approval: ApprovalRecord, - *, - artifact_path: str | None, -) -> str: - lines = [ - f"Approval: {approval.approval_id}", - f"Actor: {approval.actor_reference}", - f"Operation: {approval.operation.value}", - f"Action: {approval.action.value}", - f"Deployment plan: {approval.scope_reference}", - ] - if approval.evidence_references: - lines.append( - "Evidence: " + ", ".join(approval.evidence_references) - ) - if artifact_path: - lines.append(f"Approval artifact: {artifact_path}") - return "\n".join(lines) From 9d4ce6a94079d83e7c92004f866c4be18a22aaf6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:40:56 +1000 Subject: [PATCH 188/616] test(deployment): cover finalized-release deployment lifecycle --- tests/test_deployment_workflow_service.py | 323 +++++----------------- 1 file changed, 71 insertions(+), 252 deletions(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 5f95a23e..d8c9929c 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -10,14 +10,11 @@ ) from pydantic import ValidationError as PydanticValidationError -from semapact.approval import build_approval_record -from semapact.application.models.deployment_workflow import DeploymentBundle from semapact.application.services.contract_release_history import ( ContractReleaseHistoryService, ) -from semapact.application.services.deployment_approval import DeploymentApprovalResolver from semapact.application.services.deployment_workflow import DeploymentWorkflowService -from semapact.contractops import ReviewEvidenceAction +from semapact.application.services.release_workflow import ReleaseWorkflowService from semapact.deployment import ( DeploymentAdapter, DeploymentPreview, @@ -26,8 +23,7 @@ NativeOperationKind, ) from semapact.deployment.models import compute_deployment_preview_id -from semapact.exceptions import ContractOpsAuthorizationError, ValidationError -from semapact.governance import DecisionResult, GovernanceOperation +from semapact.governance import DecisionResult from semapact.observation import ObservedPlatformState, with_observed_state_fingerprint from semapact.platforms.git import GitWorkingTreeHistoryRepository from semapact.reconciliation import ReconciliationResult, RuntimeDriftStatus @@ -200,30 +196,34 @@ def _contract( ) -def _target() -> DeploymentTarget: +def _target(name: str = "sales") -> DeploymentTarget: return DeploymentTarget( platform="fake", - runtime_target="main.sales", - source_reference="runtime:test", + runtime_target=f"main.{name}", + source_reference=f"runtime:{name}", ) -def _release_history(tmp_path) -> ContractReleaseHistoryService: - return ContractReleaseHistoryService( - GitWorkingTreeHistoryRepository(tmp_path) - ) - - -def _review_release_bundle() -> DeploymentBundle: - return DeploymentWorkflowService().assess( +def _finalized_release(tmp_path): + workflow = ReleaseWorkflowService() + bundle = workflow.assess( _contract(name="Orders"), _contract(name="Orders", include_created_at=True), effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", - target=_target(), - adapter=_PreviewAdapter(), - release=True, + ) + approval = workflow.approve( + bundle, + actor_reference="human:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + ) + repository = GitWorkingTreeHistoryRepository(tmp_path) + repository.put_approval_record(approval) + return workflow.finalize( + bundle, + release_history=ContractReleaseHistoryService(repository), + approval=approval, ) @@ -239,74 +239,56 @@ def test_candidate_assessment_does_not_calculate_release_version() -> None: ) assert bundle.release is False - assert bundle.release_plan is None - assert bundle.version_resolution is None - assert bundle.release_snapshot is None + assert bundle.contract_release is None + assert bundle.decision is not None + assert bundle.change_set is not None assert bundle.deployment_source.release is False assert bundle.deployment_source.contract_version == "1.2.3" assert bundle.deployment_plan.release is False - assert bundle.deployment_plan.contract_version == "1.2.3" assert bundle.deployment_plan.plan_version == "4" -def test_release_assessment_resolves_version_once_and_binds_source() -> None: - bundle = _review_release_bundle() +def test_finalized_release_assessment_binds_exact_release_record(tmp_path) -> None: + release = _finalized_release(tmp_path) + bundle = DeploymentWorkflowService().assess_release( + release, + target=_target(), + adapter=_PreviewAdapter(), + ) assert bundle.release is True - assert bundle.release_plan is not None - assert bundle.version_resolution is not None - assert bundle.release_snapshot is not None - assert bundle.deployment_source.release is True - assert ( - bundle.deployment_source.release_id - == bundle.release_snapshot.release_snapshot_id - ) - assert ( - bundle.deployment_source.contract_version - == bundle.version_resolution.selected_version - ) - assert bundle.deployment_plan.release is True - assert bundle.deployment_plan.plan_version == "4" + assert bundle.decision is None + assert bundle.change_set is None + assert bundle.contract_release == release + assert bundle.deployment_source.release_id == release.contract_release_id + assert bundle.deployment_source.contract_version == release.contract_version + assert bundle.deployment_plan.release_id == release.contract_release_id -def test_same_inputs_produce_same_bundle_digest() -> None: - first = DeploymentWorkflowService().assess( - _contract(name="Orders old"), - _contract(name="Orders new"), +def test_same_candidate_inputs_produce_same_bundle_digest() -> None: + service = DeploymentWorkflowService() + kwargs = dict( effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", target=_target(), + ) + first = service.assess( + _contract(name="Orders old"), + _contract(name="Orders new"), adapter=_PreviewAdapter(), + **kwargs, ) - second = DeploymentWorkflowService().assess( + second = service.assess( _contract(name="Orders old"), _contract(name="Orders new"), - effective_date="2026-09-20", - base_revision_ref="git:base", - candidate_revision_ref="git:candidate", - target=_target(), adapter=_PreviewAdapter(), + **kwargs, ) assert first == second assert first.bundle_digest == second.bundle_digest -def test_release_mode_changes_bundle_identity() -> None: - candidate = DeploymentWorkflowService().assess( - _contract(name="Orders"), - _contract(name="Orders", include_created_at=True), - effective_date="2026-09-20", - base_revision_ref="git:base", - candidate_revision_ref="git:candidate", - target=_target(), - adapter=_PreviewAdapter(), - ) - release = _review_release_bundle() - - assert candidate.bundle_digest != release.bundle_digest - - def test_bundle_rehydration_fails_closed_when_digest_is_tampered() -> None: bundle = DeploymentWorkflowService().assess( _contract(name="Orders old"), @@ -321,10 +303,10 @@ def test_bundle_rehydration_fails_closed_when_digest_is_tampered() -> None: payload["bundle_digest"] = "sha256:" + ("0" * 64) with pytest.raises(PydanticValidationError, match="digest does not match"): - DeploymentBundle.model_validate(payload) + type(bundle).model_validate(payload) -def test_candidate_review_deployment_needs_no_approval_or_release_history() -> None: +def test_candidate_review_deployment_needs_no_release_approval() -> None: service = DeploymentWorkflowService() bundle = service.assess( _contract(name="Orders"), @@ -335,8 +317,8 @@ def test_candidate_review_deployment_needs_no_approval_or_release_history() -> N target=_target(), adapter=_PreviewAdapter(), ) + assert bundle.decision is not None assert bundle.decision.decision is DecisionResult.REVIEW - assert bundle.release is False result = service.deploy(bundle, adapter=_ExecutionAdapter()) @@ -392,212 +374,49 @@ def test_configured_operational_history_records_failed_candidate_deployment() -> assert event.status == "FAILED" assert event.release is False assert event.contract_release_id is None - assert event.deployment_preview_id is not None assert event.reconciliation_status is None -def test_non_release_bundle_rejects_manual_approval() -> None: +def test_one_finalized_release_fans_out_to_multiple_targets(tmp_path) -> None: + release = _finalized_release(tmp_path) service = DeploymentWorkflowService() - bundle = service.assess( - _contract(name="Orders"), - _contract(name="Orders", include_created_at=True), - effective_date="2026-09-20", - base_revision_ref="git:base", - candidate_revision_ref="git:candidate", - target=_target(), - adapter=_PreviewAdapter(), - ) - - with pytest.raises(ValidationError, match="Non-release"): - service.approve( - bundle, - actor_reference="human:reviewer", - recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), - ) - -def test_release_approval_is_derived_from_exact_bundle() -> None: - service = DeploymentWorkflowService() - bundle = _review_release_bundle() - - approval = service.approve( - bundle, - actor_reference="human:reviewer", - recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), - ) - - assert approval.operation is GovernanceOperation.DEPLOY - assert approval.action is ReviewEvidenceAction.APPROVE - assert approval.scope_reference == bundle.deployment_plan.deployment_plan_id - assert approval.evidence_references == (bundle.bundle_digest,) - - -def test_review_release_requires_approval_and_records_formal_release(tmp_path) -> None: - service = DeploymentWorkflowService() - bundle = _review_release_bundle() - - with pytest.raises(ContractOpsAuthorizationError, match="requires approval"): - service.deploy( - bundle, - adapter=_ExecutionAdapter(), - release_history=_release_history(tmp_path), - ) - - approval = service.approve( - bundle, - actor_reference="human:reviewer", - recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), - ) - result = service.deploy( - bundle, - adapter=_ExecutionAdapter(), - approval=approval, - release_history=_release_history(tmp_path), - ) - - assert result.contract_release_id is not None - repository = GitWorkingTreeHistoryRepository(tmp_path) - record = repository.get_contract_release(result.contract_release_id) - assert record.contract_version == bundle.version_resolution.selected_version - assert record.release_snapshot_id == bundle.release_snapshot.release_snapshot_id - assert record.revision_ref == bundle.release_snapshot.release_revision_ref - - -def test_contract_release_identity_is_target_neutral(tmp_path) -> None: - service = DeploymentWorkflowService() - base = _contract(name="Orders") - candidate = _contract(name="Orders", include_created_at=True) - - dev = service.assess( - base, - candidate, - effective_date="2026-09-20", - base_revision_ref="git:base", - candidate_revision_ref="git:candidate", - target=DeploymentTarget( - platform="fake", - runtime_target="main.dev", - source_reference="runtime:dev", - ), + dev = service.assess_release( + release, + target=_target("dev"), adapter=_PreviewAdapter(), - release=True, ) - prod = service.assess( - base, - candidate, - effective_date="2026-09-20", - base_revision_ref="git:base", - candidate_revision_ref="git:candidate", - target=DeploymentTarget( - platform="fake", - runtime_target="main.prod", - source_reference="runtime:prod", - ), + prod = service.assess_release( + release, + target=_target("prod"), adapter=_PreviewAdapter(), - release=True, ) + + assert dev.contract_release == prod.contract_release == release assert dev.bundle_digest != prod.bundle_digest assert dev.deployment_plan.deployment_plan_id != prod.deployment_plan.deployment_plan_id + assert dev.deployment_source.release_id == prod.deployment_source.release_id - history = _release_history(tmp_path) - dev_approval = service.approve( - dev, - actor_reference="human:reviewer", - recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), - ) - prod_approval = service.approve( - prod, - actor_reference="human:reviewer", - recorded_at=datetime(2026, 9, 20, 2, 1, tzinfo=timezone.utc), - ) - first = history.record_release(dev, approval=dev_approval) - second = history.record_release(prod, approval=prod_approval) - - assert first == second - assert first.contract_release_id == second.contract_release_id - - -def test_release_projects_metadata_only_after_in_sync(tmp_path) -> None: - service = DeploymentWorkflowService() - bundle = _review_release_bundle() - approval = service.approve( - bundle, - actor_reference="human:reviewer", - recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), +def test_release_deployment_projects_finalized_version_after_in_sync(tmp_path) -> None: + release = _finalized_release(tmp_path) + bundle = DeploymentWorkflowService().assess_release( + release, + target=_target(), + adapter=_PreviewAdapter(), ) projector = _MetadataProjector() - result = service.deploy( + result = DeploymentWorkflowService().deploy( bundle, adapter=_ExecutionAdapter(), - approval=approval, - release_history=_release_history(tmp_path), metadata_projector=projector, ) assert result.status is RuntimeDriftStatus.IN_SYNC + assert result.contract_release_id == release.contract_release_id assert len(projector.calls) == 1 plan, metadata = projector.calls[0] - assert metadata.contract_version == plan.contract_version - assert metadata.contract_release_id == result.contract_release_id - - -def test_review_release_rejects_approval_for_different_bundle_digest(tmp_path) -> None: - service = DeploymentWorkflowService() - bundle = _review_release_bundle() - assert bundle.release_plan is not None - assert bundle.version_resolution is not None - approval = build_approval_record( - decision_id=bundle.decision.decision_id, - change_set_id=bundle.change_set.change_set_id, - release_plan_id=bundle.release_plan.release_plan_id, - version_resolution_id=bundle.version_resolution.version_resolution_id, - operation=GovernanceOperation.DEPLOY, - action=ReviewEvidenceAction.APPROVE, - actor_reference="human:reviewer", - recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), - scope_reference=bundle.deployment_plan.deployment_plan_id, - evidence_references=("sha256:" + ("0" * 64),), - ) - - with pytest.raises(ValidationError, match="exact DeploymentBundle digest"): - service.deploy( - bundle, - adapter=_ExecutionAdapter(), - approval=approval, - release_history=_release_history(tmp_path), - ) - - -def test_deployment_approval_resolver_finds_exact_release_record(tmp_path) -> None: - service = DeploymentWorkflowService() - bundle = _review_release_bundle() - approval = service.approve( - bundle, - actor_reference="github:user:reviewer", - recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), - ) - repository = GitWorkingTreeHistoryRepository(tmp_path) - repository.put_approval_record(approval) - - assert DeploymentApprovalResolver(repository).resolve(bundle) == approval - - -def test_deployment_approval_resolver_ignores_candidate_bundle(tmp_path) -> None: - bundle = DeploymentWorkflowService().assess( - _contract(name="Orders"), - _contract(name="Orders", include_created_at=True), - effective_date="2026-09-20", - base_revision_ref="git:base", - candidate_revision_ref="git:candidate", - target=_target(), - adapter=_PreviewAdapter(), - ) - - assert ( - DeploymentApprovalResolver( - GitWorkingTreeHistoryRepository(tmp_path) - ).resolve(bundle) - is None - ) + assert metadata.contract_version == release.contract_version + assert metadata.contract_release_id == release.contract_release_id + assert plan.release_id == release.contract_release_id From 75f04657ef7adbbcd9fb9bd43c30259d53e04858 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:41:09 +1000 Subject: [PATCH 189/616] test(release): cover target-neutral assess approval finalize --- tests/test_release_workflow_service.py | 144 +++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/test_release_workflow_service.py diff --git a/tests/test_release_workflow_service.py b/tests/test_release_workflow_service.py new file mode 100644 index 00000000..b4d48550 --- /dev/null +++ b/tests/test_release_workflow_service.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from open_data_contract_standard.model import ( + OpenDataContractStandard, + SchemaObject, + SchemaProperty, +) +from pydantic import ValidationError as PydanticValidationError + +from semapact.application.models.release import ReleaseBundle +from semapact.application.services.contract_release_history import ( + ContractReleaseHistoryService, +) +from semapact.application.services.release_approval import ReleaseApprovalResolver +from semapact.application.services.release_workflow import ReleaseWorkflowService +from semapact.exceptions import ContractOpsAuthorizationError +from semapact.governance import DecisionResult, GovernanceOperation +from semapact.platforms.git import GitWorkingTreeHistoryRepository + + +def _contract(*, include_created_at: bool = False) -> OpenDataContractStandard: + properties = [ + SchemaProperty( + name="id", + logicalType="string", + physicalType="varchar(255)", + required=True, + ) + ] + if include_created_at: + properties.append( + SchemaProperty( + name="created_at", + logicalType="timestamp", + physicalType="timestamp", + required=False, + ) + ) + return OpenDataContractStandard( + apiVersion="v3.1.0", + kind="DataContract", + id="orders-product", + name="Orders", + version="1.2.3", + status="active", + schema=[SchemaObject(name="orders", properties=properties)], + ) + + +def _bundle() -> ReleaseBundle: + return ReleaseWorkflowService().assess( + _contract(), + _contract(include_created_at=True), + effective_date="2026-09-20", + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + ) + + +def test_release_assess_is_target_neutral_and_resolves_version_once() -> None: + bundle = _bundle() + + assert bundle.decision.decision is DecisionResult.REVIEW + assert bundle.version_resolution.current_version == "1.2.3" + assert bundle.version_resolution.selected_version == "1.3.0" + assert bundle.release_snapshot.selected_version == "1.3.0" + assert "target" not in bundle.model_dump(mode="json") + + +def test_release_bundle_digest_is_deterministic_and_tamper_evident() -> None: + first = _bundle() + second = _bundle() + assert first == second + assert first.bundle_digest == second.bundle_digest + + payload = first.model_dump(mode="json") + payload["bundle_digest"] = "sha256:" + ("0" * 64) + with pytest.raises(PydanticValidationError, match="digest does not match"): + ReleaseBundle.model_validate(payload) + + +def test_release_approval_binds_publish_scope_and_exact_bundle() -> None: + bundle = _bundle() + approval = ReleaseWorkflowService().approve( + bundle, + actor_reference="github:user:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + ) + + assert approval.operation is GovernanceOperation.PUBLISH + assert approval.scope_reference == bundle.release_snapshot.release_snapshot_id + assert approval.evidence_references == (bundle.bundle_digest,) + + +def test_review_release_finalize_requires_exact_approval(tmp_path) -> None: + bundle = _bundle() + repository = GitWorkingTreeHistoryRepository(tmp_path) + workflow = ReleaseWorkflowService() + + with pytest.raises(ContractOpsAuthorizationError, match="requires approval"): + workflow.finalize( + bundle, + release_history=ContractReleaseHistoryService(repository), + ) + + approval = workflow.approve( + bundle, + actor_reference="github:user:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + ) + repository.put_approval_record(approval) + record = workflow.finalize( + bundle, + release_history=ContractReleaseHistoryService(repository), + approval=approval, + ) + + assert record.contract_version == "1.3.0" + assert record.release_snapshot_id == bundle.release_snapshot.release_snapshot_id + assert ( + repository.get_contract_release(record.contract_release_id) + == record + ) + + +def test_release_approval_resolver_finds_only_exact_bundle(tmp_path) -> None: + bundle = _bundle() + repository = GitWorkingTreeHistoryRepository(tmp_path) + approval = ReleaseWorkflowService().approve( + bundle, + actor_reference="github:user:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + ) + repository.put_approval_record(approval) + + assert ReleaseApprovalResolver(repository).resolve(bundle) == approval + + tampered = bundle.model_copy( + update={"bundle_digest": "sha256:" + ("f" * 64)} + ) + assert ReleaseApprovalResolver(repository).resolve(tampered) is None From bac795b760ea1e7b5fc3cf9b57556de959a190f4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:41:35 +1000 Subject: [PATCH 190/616] test(cli): cover separated release and deployment surfaces --- tests/interfaces/test_deployment_cmd.py | 102 ++++++++++++++++-------- 1 file changed, 67 insertions(+), 35 deletions(-) diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index 6ff90471..3eccf7fe 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -15,10 +15,10 @@ SOURCE_REFERENCE = "https://workspace.example" -def test_deployment_parser_exposes_only_bundle_workflow() -> None: +def test_deployment_parser_exposes_candidate_and_finalized_release_modes() -> None: parser = cli._build_parser() - assess = parser.parse_args( + candidate = parser.parse_args( [ "deployment", "assess", @@ -33,28 +33,23 @@ def test_deployment_parser_exposes_only_bundle_workflow() -> None: "--effective-date", "2026-09-20", "--server", - "production", + "development", "--bundle-out", - "bundle.json", - "--release", + "candidate.bundle.json", "--output", "json", ] ) - approve = parser.parse_args( + release = parser.parse_args( [ "deployment", - "approve", - "--bundle", - "bundle.json", - "--actor-reference", - "human:reviewer", - "--recorded-at", - "2026-09-20T10:00:00+10:00", - "--approval-out", - "approval.json", - "--output", - "json", + "assess", + "--release-id", + "release-1", + "--server", + "production", + "--bundle-out", + "release.bundle.json", ] ) deploy = parser.parse_args( @@ -63,8 +58,6 @@ def test_deployment_parser_exposes_only_bundle_workflow() -> None: "deploy", "--bundle", "bundle.json", - "--approval", - "approval.json", "--warehouse-id", "warehouse-1", "--operational-history", @@ -74,34 +67,39 @@ def test_deployment_parser_exposes_only_bundle_workflow() -> None: ] ) - assert assess.deployment_command == "assess" - assert assess.server == "production" - assert assess.bundle_out == "bundle.json" - assert assess.release is True - assert assess.output == "json" + assert candidate.deployment_command == "assess" + assert candidate.release_id is None + assert candidate.server == "development" + assert candidate.bundle_out == "candidate.bundle.json" - assert approve.deployment_command == "approve" - assert approve.bundle == "bundle.json" - assert approve.actor_reference == "human:reviewer" - assert approve.approval_out == "approval.json" + assert release.deployment_command == "assess" + assert release.release_id == "release-1" + assert release.repository_root == "." + assert release.server == "production" assert deploy.deployment_command == "deploy" assert deploy.bundle == "bundle.json" - assert deploy.approval == "approval.json" - assert deploy.repository_root == "." assert deploy.warehouse_id == "warehouse-1" assert deploy.operational_history == "sqlite:///history.db" assert deploy.output == "json" -@pytest.mark.parametrize("legacy_command", ["plan", "preview", "execute", "verify"]) -def test_legacy_deployment_commands_are_not_public_cli( - legacy_command: str, -) -> None: +@pytest.mark.parametrize( + "legacy_args", + [ + ["deployment", "plan"], + ["deployment", "preview"], + ["deployment", "execute"], + ["deployment", "verify"], + ["deployment", "approve"], + ["deployment", "assess", "--release"], + ], +) +def test_legacy_deployment_cli_surfaces_are_not_public(legacy_args) -> None: parser = cli._build_parser() with pytest.raises(SystemExit) as exc: - parser.parse_args(["deployment", legacy_command]) + parser.parse_args(legacy_args) assert exc.value.code == 2 @@ -135,6 +133,40 @@ def test_main_preserves_bundle_deploy_outcome_semantics( assert capsys.readouterr().out.strip() == "deployment" +def test_candidate_assessment_requires_candidate_arguments() -> None: + args = type( + "Args", + (), + { + "base": None, + "candidate": None, + "base_revision_ref": None, + "candidate_revision_ref": None, + "effective_date": None, + }, + )() + + with pytest.raises(ValidationError, match="Candidate deployment assessment"): + deployment_cmd._require_candidate_args(args) + + +def test_release_assessment_rejects_candidate_arguments() -> None: + args = type( + "Args", + (), + { + "base": "base.yaml", + "candidate": None, + "base_revision_ref": None, + "candidate_revision_ref": None, + "effective_date": None, + }, + )() + + with pytest.raises(ValidationError, match="cannot be combined"): + deployment_cmd._reject_candidate_args_for_release(args) + + def test_assessment_source_reference_prefers_contract_server_host() -> None: server = Server.model_validate( { From 89fdbd01e128c4553db1e901e29bdcc05df0f65b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:42:18 +1000 Subject: [PATCH 191/616] refactor(deployment): remove obsolete deployment approval resolver --- .../services/deployment_approval.py | 51 ------------------- 1 file changed, 51 deletions(-) delete mode 100644 semapact/application/services/deployment_approval.py diff --git a/semapact/application/services/deployment_approval.py b/semapact/application/services/deployment_approval.py deleted file mode 100644 index 612e483b..00000000 --- a/semapact/application/services/deployment_approval.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Resolve exact deployment approval evidence from immutable approval history.""" - -from __future__ import annotations - -from semapact.approval import ApprovalRecord -from semapact.application.models.deployment_workflow import DeploymentBundle -from semapact.contractops import ReviewEvidenceAction -from semapact.governance import GovernanceOperation -from semapact.history import ApprovalHistoryRepository - - -class DeploymentApprovalResolver: - """Select exact APPROVE evidence for one immutable DeploymentBundle. - - History persistence remains storage-only. This resolver owns the deployment-specific - selection rule: evidence must match the exact ContractOps context, DEPLOY scope, - deployment plan, and bundle digest. Conflicting exact review actions fail closed. - Multiple exact APPROVE records are equivalent for authorization; selection is - deterministic by canonical approval identity. - """ - - def __init__(self, approvals: ApprovalHistoryRepository) -> None: - self._approvals = approvals - - def resolve(self, bundle: DeploymentBundle) -> ApprovalRecord | None: - if not bundle.release: - return None - if bundle.release_plan is None or bundle.version_resolution is None: - return None - records = self._approvals.list_approval_records_for_context( - decision_id=bundle.decision.decision_id, - change_set_id=bundle.change_set.change_set_id, - release_plan_id=bundle.release_plan.release_plan_id, - version_resolution_id=bundle.version_resolution.version_resolution_id, - operation=GovernanceOperation.DEPLOY, - ) - scoped = tuple( - record - for record in records - if record.scope_reference - == bundle.deployment_plan.deployment_plan_id - and bundle.bundle_digest in record.evidence_references - ) - if not scoped: - return None - if any( - record.action is not ReviewEvidenceAction.APPROVE - for record in scoped - ): - return None - return min(scoped, key=lambda record: record.approval_id) From d6fe111ed031d102ef3df3278deca249ffad9374 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:42:28 +1000 Subject: [PATCH 192/616] test(cli): cover target-neutral release workflow commands --- tests/interfaces/test_release_plan_cmd.py | 83 +++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/interfaces/test_release_plan_cmd.py b/tests/interfaces/test_release_plan_cmd.py index f372cc01..1e794c43 100644 --- a/tests/interfaces/test_release_plan_cmd.py +++ b/tests/interfaces/test_release_plan_cmd.py @@ -100,3 +100,86 @@ def test_legacy_release_helpers_are_labeled_as_compatibility_paths() -> None: assert "release" in help_text assert "Compatibility helper" in release_help assert "Compatibility Git workflow" in release_help + + + +def test_release_parser_exposes_assess_approve_finalize() -> None: + parser = cli._build_parser() + + assess = parser.parse_args( + [ + "release", + "assess", + "--base", + "base.yaml", + "--candidate", + "candidate.yaml", + "--base-revision-ref", + "git:base", + "--candidate-revision-ref", + "git:candidate", + "--effective-date", + "2026-09-20", + "--bundle-out", + "release.bundle.json", + ] + ) + approve = parser.parse_args( + [ + "release", + "approve", + "--bundle", + "release.bundle.json", + "--actor-reference", + "github-environment:contract-release", + "--recorded-at", + "2026-09-20T10:00:00+10:00", + ] + ) + finalize = parser.parse_args( + [ + "release", + "finalize", + "--bundle", + "release.bundle.json", + "--output-contract", + "contract.yaml", + ] + ) + + assert assess.release_command == "assess" + assert assess.bundle_out == "release.bundle.json" + assert approve.release_command == "approve" + assert approve.repository_root == "." + assert finalize.release_command == "finalize" + assert finalize.output_contract == "contract.yaml" + + +def test_main_routes_release_assess_to_release_command_adapter( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + expected = {"bundle_digest": "sha256:test"} + monkeypatch.setattr(release_cmd, "run_release_assess", lambda args: expected) + monkeypatch.setattr( + sys, + "argv", + [ + "semapact", + "release", + "assess", + "--base", + "base.yaml", + "--candidate", + "candidate.yaml", + "--base-revision-ref", + "git:base", + "--candidate-revision-ref", + "git:candidate", + "--effective-date", + "2026-09-20", + ], + ) + + assert cli.main() == 0 + assert json.loads(capsys.readouterr().out) == expected From b6821b609888851b2b7d1beafd086e21936a9801 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:43:09 +1000 Subject: [PATCH 193/616] refactor(examples): separate data product release from deployment --- examples/github/data-product-ci-cd.yml | 130 ++++++++++++++++--------- 1 file changed, 84 insertions(+), 46 deletions(-) diff --git a/examples/github/data-product-ci-cd.yml b/examples/github/data-product-ci-cd.yml index f9701285..d5cea487 100644 --- a/examples/github/data-product-ci-cd.yml +++ b/examples/github/data-product-ci-cd.yml @@ -58,7 +58,7 @@ jobs: --candidate-revision-ref "git:${CANDIDATE_SHA}" \ --effective-date "$(date -u +%F)" \ --server "${DEV_SERVER}" \ - --bundle-out artifacts/candidate.bundle.json \ + --bundle-out artifacts/candidate.deployment.bundle.json \ --output json > artifacts/candidate.summary.json - name: Upload candidate assessment @@ -68,10 +68,11 @@ jobs: path: artifacts/ release-assess: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: > + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + !contains(github.event.head_commit.message, '[semapact-release]') runs-on: ubuntu-latest - env: - DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} steps: - name: Checkout release candidate uses: actions/checkout@v4 @@ -84,30 +85,28 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install SemaPact - run: pip install "semapact[databricks]" + run: pip install semapact - - name: Materialize previous contract + - name: Materialize previous released contract env: BASE_SHA: ${{ github.event.before }} run: | git show "${BASE_SHA}:${CONTRACT_PATH}" > /tmp/base-contract.yaml - - name: Build formal release bundle + - name: Build target-neutral formal release bundle env: BASE_SHA: ${{ github.event.before }} CANDIDATE_SHA: ${{ github.sha }} run: | mkdir -p artifacts - semapact deployment assess \ + semapact release assess \ --base /tmp/base-contract.yaml \ --candidate "${CONTRACT_PATH}" \ --base-revision-ref "git:${BASE_SHA}" \ --candidate-revision-ref "git:${CANDIDATE_SHA}" \ --effective-date "$(date -u +%F)" \ - --server "${PROD_SERVER}" \ - --release \ --bundle-out artifacts/release.bundle.json \ - --output json > artifacts/release.summary.json + > artifacts/release.summary.json - name: Upload exact release bundle uses: actions/upload-artifact@v4 @@ -115,16 +114,20 @@ jobs: name: semapact-release-bundle path: artifacts/ - deploy-production: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + finalize-release: + if: > + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + !contains(github.event.head_commit.message, '[semapact-release]') needs: release-assess runs-on: ubuntu-latest - environment: production - env: - DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} - DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }} + # Example policy: GitHub Environment protects formal release publication. + # SemaPact only persists an ApprovalRecord when its decision is REVIEW. + environment: contract-release + outputs: + release_id: ${{ steps.finalize.outputs.release_id }} steps: - - name: Checkout governance ledger + - name: Checkout main uses: actions/checkout@v4 with: fetch-depth: 0 @@ -135,7 +138,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install SemaPact - run: pip install "semapact[databricks]" + run: pip install semapact - name: Download exact release bundle uses: actions/download-artifact@v4 @@ -143,43 +146,78 @@ jobs: name: semapact-release-bundle path: artifacts - # GitHub Environment is the human approval surface. Today the generic - # approval CLI still needs the exact bundle fields projected into the - # Git governance ledger; this is intentionally visible in the example. - - name: Record environment approval when governance requires REVIEW + - name: Persist exact review approval when required run: | BUNDLE=artifacts/release.bundle.json if [ "$(jq -r '.decision.decision' "${BUNDLE}")" = "REVIEW" ]; then - semapact approval record \ - --decision-id "$(jq -r '.decision.decision_id' "${BUNDLE}")" \ - --change-set-id "$(jq -r '.change_set.change_set_id' "${BUNDLE}")" \ - --release-plan-id "$(jq -r '.release_plan.release_plan_id' "${BUNDLE}")" \ - --version-resolution-id "$(jq -r '.version_resolution.version_resolution_id' "${BUNDLE}")" \ - --operation DEPLOY \ - --action APPROVE \ - --actor-reference "github-environment:production/run:${GITHUB_RUN_ID}" \ - --recorded-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - --scope-reference "$(jq -r '.deployment_plan.deployment_plan_id' "${BUNDLE}")" \ - --capability-reference "github-environment:production" \ - --evidence-reference "$(jq -r '.bundle_digest' "${BUNDLE}")" + semapact release approve \ + --bundle "${BUNDLE}" \ + --actor-reference "github-environment:contract-release/run:${GITHUB_RUN_ID}" \ + --recorded-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" fi - - name: Deploy exact release bundle + - name: Finalize release and materialize released contract + id: finalize run: | - semapact deployment deploy \ + semapact release finalize \ --bundle artifacts/release.bundle.json \ - --warehouse-id "${DATABRICKS_WAREHOUSE_ID}" + --output-contract "${CONTRACT_PATH}" \ + > artifacts/finalize.json + RELEASE_ID=$(jq -r '.contractReleaseId' artifacts/finalize.json) + echo "release_id=${RELEASE_ID}" >> "${GITHUB_OUTPUT}" - - name: Commit governance ledger + - name: Commit released contract and governance ledger once run: | - if [ -n "$(git status --porcelain .semapact/history 2>/dev/null)" ]; then - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add .semapact/history - git commit -m "chore(semapact): record contract release" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "${CONTRACT_PATH}" .semapact/history + if ! git diff --cached --quiet; then + git commit -m "chore(semapact): finalize contract release [semapact-release]" git pull --rebase origin main git push origin HEAD:main fi - # Operational deployment history is not passed here. If configured under - # history.operational in .semapact.yaml, deploy writes to that backend. + - name: Upload release result + uses: actions/upload-artifact@v4 + with: + name: semapact-finalized-release + path: artifacts/finalize.json + + deploy-production: + needs: finalize-release + runs-on: ubuntu-latest + environment: production + env: + DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} + DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }} + steps: + - name: Checkout finalized release + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install SemaPact + run: pip install "semapact[databricks]" + + - name: Assess finalized release against production + run: | + mkdir -p artifacts + semapact deployment assess \ + --release-id "${{ needs.finalize-release.outputs.release_id }}" \ + --server "${PROD_SERVER}" \ + --bundle-out artifacts/production.deployment.bundle.json + + - name: Deploy exact target bundle + run: | + semapact deployment deploy \ + --bundle artifacts/production.deployment.bundle.json \ + --warehouse-id "${DATABRICKS_WAREHOUSE_ID}" + + # Operational deployment history is config-first. If history.operational is + # configured in .semapact.yaml, deploy writes there automatically. From b793645acd4c06d88a25ff0450adf935978d3a6d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:43:39 +1000 Subject: [PATCH 194/616] refactor(examples): separate central release finalization from deployment fanout --- .../github/central-contract-repo-ci-cd.yml | 186 ++++++++++-------- 1 file changed, 109 insertions(+), 77 deletions(-) diff --git a/examples/github/central-contract-repo-ci-cd.yml b/examples/github/central-contract-repo-ci-cd.yml index 136cdc96..e9ad1035 100644 --- a/examples/github/central-contract-repo-ci-cd.yml +++ b/examples/github/central-contract-repo-ci-cd.yml @@ -23,6 +23,9 @@ env: jobs: detect-contracts: + if: > + github.event_name == 'pull_request' || + !contains(github.event.head_commit.message, '[semapact-release]') runs-on: ubuntu-latest outputs: base_sha: ${{ steps.base.outputs.sha }} @@ -137,8 +140,7 @@ jobs: --candidate-revision-ref "git:${CANDIDATE_SHA}" \ --effective-date "$(date -u +%F)" \ --server "${DEV_SERVER}" \ - --bundle-out "artifacts/${ARTIFACT}.candidate.bundle.json" \ - --output json > "artifacts/${ARTIFACT}.candidate.summary.json" + --bundle-out "artifacts/${ARTIFACT}.candidate.deployment.bundle.json" - name: Upload candidate bundle uses: actions/upload-artifact@v4 @@ -153,8 +155,6 @@ jobs: strategy: fail-fast: false matrix: ${{ fromJSON(needs.detect-contracts.outputs.matrix) }} - env: - DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} steps: - name: Checkout release candidate uses: actions/checkout@v4 @@ -167,16 +167,16 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install SemaPact - run: pip install "semapact[databricks]" + run: pip install semapact - - name: Materialize previous contract + - name: Materialize previous released contract env: BASE_SHA: ${{ needs.detect-contracts.outputs.base_sha }} CONTRACT_PATH: ${{ matrix.contract_path }} run: | git show "${BASE_SHA}:${CONTRACT_ROOT}/${CONTRACT_PATH}" > /tmp/base-contract.yaml - - name: Build formal release bundle + - name: Build target-neutral ReleaseBundle env: BASE_SHA: ${{ needs.detect-contracts.outputs.base_sha }} CANDIDATE_SHA: ${{ github.sha }} @@ -184,16 +184,13 @@ jobs: ARTIFACT: ${{ matrix.artifact }} run: | mkdir -p artifacts - semapact deployment assess \ + semapact release assess \ --base /tmp/base-contract.yaml \ --candidate "${CONTRACT_ROOT}/${CONTRACT_PATH}" \ --base-revision-ref "git:${BASE_SHA}" \ --candidate-revision-ref "git:${CANDIDATE_SHA}" \ --effective-date "$(date -u +%F)" \ - --server "${PROD_SERVER}" \ - --release \ - --bundle-out "artifacts/${ARTIFACT}.release.bundle.json" \ - --output json > "artifacts/${ARTIFACT}.release.summary.json" + --bundle-out "artifacts/${ARTIFACT}.release.bundle.json" - name: Upload exact release bundle uses: actions/upload-artifact@v4 @@ -201,21 +198,17 @@ jobs: name: release-${{ matrix.artifact }} path: artifacts/ - deploy-production: + finalize-releases: if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-contracts.outputs.has_changes == 'true' needs: - detect-contracts - release-assess runs-on: ubuntu-latest - environment: production - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.detect-contracts.outputs.matrix) }} - env: - DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} - DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }} + environment: contract-release + outputs: + manifest_artifact: finalized-release-manifest steps: - - name: Checkout governance ledger + - name: Checkout main uses: actions/checkout@v4 with: fetch-depth: 0 @@ -226,82 +219,121 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install SemaPact - run: pip install "semapact[databricks]" + run: pip install semapact - - name: Download exact contract release bundle + - name: Download all target-neutral release bundles uses: actions/download-artifact@v4 with: - name: release-${{ matrix.artifact }} - path: artifacts + pattern: release-* + path: artifacts/releases + merge-multiple: true - # This projection is deliberately verbose: it exposes the current gap - # between a GitHub Environment approval event and SemaPact ApprovalRecord. - - name: Record environment approval when governance requires REVIEW + - name: Approve REVIEW releases, finalize all releases, and materialize versions env: - ARTIFACT: ${{ matrix.artifact }} + MATRIX: ${{ needs.detect-contracts.outputs.matrix }} run: | - BUNDLE="artifacts/${ARTIFACT}.release.bundle.json" - if [ "$(jq -r '.decision.decision' "${BUNDLE}")" = "REVIEW" ]; then - semapact approval record \ - --decision-id "$(jq -r '.decision.decision_id' "${BUNDLE}")" \ - --change-set-id "$(jq -r '.change_set.change_set_id' "${BUNDLE}")" \ - --release-plan-id "$(jq -r '.release_plan.release_plan_id' "${BUNDLE}")" \ - --version-resolution-id "$(jq -r '.version_resolution.version_resolution_id' "${BUNDLE}")" \ - --operation DEPLOY \ - --action APPROVE \ - --actor-reference "github-environment:production/run:${GITHUB_RUN_ID}" \ - --recorded-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - --scope-reference "$(jq -r '.deployment_plan.deployment_plan_id' "${BUNDLE}")" \ - --capability-reference "github-environment:production" \ - --evidence-reference "$(jq -r '.bundle_digest' "${BUNDLE}")" - fi - - - name: Deploy contract release - env: - ARTIFACT: ${{ matrix.artifact }} + echo '[]' > artifacts/finalized-release-manifest.json + echo "${MATRIX}" | jq -c '.include[]' | while read -r ITEM; do + CONTRACT_PATH=$(echo "${ITEM}" | jq -r '.contract_path') + ARTIFACT=$(echo "${ITEM}" | jq -r '.artifact') + BUNDLE="artifacts/releases/${ARTIFACT}.release.bundle.json" + + if [ "$(jq -r '.decision.decision' "${BUNDLE}")" = "REVIEW" ]; then + semapact release approve \ + --bundle "${BUNDLE}" \ + --actor-reference "github-environment:contract-release/run:${GITHUB_RUN_ID}" \ + --recorded-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + fi + + semapact release finalize \ + --bundle "${BUNDLE}" \ + --output-contract "${CONTRACT_ROOT}/${CONTRACT_PATH}" \ + > /tmp/finalize.json + + RELEASE_ID=$(jq -r '.contractReleaseId' /tmp/finalize.json) + jq --arg artifact "${ARTIFACT}" \ + --arg contract_path "${CONTRACT_PATH}" \ + --arg release_id "${RELEASE_ID}" \ + '. + [{artifact: $artifact, contract_path: $contract_path, release_id: $release_id}]' \ + artifacts/finalized-release-manifest.json > /tmp/manifest.json + mv /tmp/manifest.json artifacts/finalized-release-manifest.json + done + + - name: Commit all released contracts and governance facts once run: | - semapact deployment deploy \ - --bundle "artifacts/${ARTIFACT}.release.bundle.json" \ - --warehouse-id "${DATABRICKS_WAREHOUSE_ID}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "${CONTRACT_ROOT}" .semapact/history + if ! git diff --cached --quiet; then + git commit -m "chore(semapact): finalize contract releases [semapact-release]" + git pull --rebase origin main + git push origin HEAD:main + fi - - name: Upload governance-ledger fragment - if: always() + - name: Upload finalized release manifest uses: actions/upload-artifact@v4 with: - name: ledger-${{ matrix.artifact }} - path: .semapact/history/ - if-no-files-found: ignore + name: finalized-release-manifest + path: artifacts/finalized-release-manifest.json - # Operational history is config-first. No per-contract CLI flag is needed - # when history.operational is present in .semapact.yaml. - - commit-governance-ledger: - if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-contracts.outputs.has_changes == 'true' && needs.release-assess.result == 'success' + deploy-production: needs: - detect-contracts - - release-assess - - deploy-production + - finalize-releases runs-on: ubuntu-latest + environment: production + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.detect-contracts.outputs.matrix) }} + env: + DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} + DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }} steps: - - name: Checkout main + - name: Checkout finalized governance ledger uses: actions/checkout@v4 with: + ref: main fetch-depth: 0 - - name: Download governance-ledger fragments + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install SemaPact + run: pip install "semapact[databricks]" + + - name: Download finalized release manifest uses: actions/download-artifact@v4 with: - pattern: ledger-* - path: .semapact/history - merge-multiple: true + name: finalized-release-manifest + path: artifacts - - name: Commit governance ledger once + - name: Resolve finalized release identity + id: release + env: + ARTIFACT: ${{ matrix.artifact }} run: | - if [ -n "$(git status --porcelain .semapact/history 2>/dev/null)" ]; then - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add .semapact/history - git commit -m "chore(semapact): record contract releases" - git pull --rebase origin main - git push origin HEAD:main - fi + RELEASE_ID=$(jq -r --arg artifact "${ARTIFACT}" '.[] | select(.artifact == $artifact) | .release_id' artifacts/finalized-release-manifest.json) + test -n "${RELEASE_ID}" && test "${RELEASE_ID}" != "null" + echo "release_id=${RELEASE_ID}" >> "${GITHUB_OUTPUT}" + + - name: Assess finalized release against production + env: + ARTIFACT: ${{ matrix.artifact }} + run: | + mkdir -p artifacts/deploy + semapact deployment assess \ + --release-id "${{ steps.release.outputs.release_id }}" \ + --server "${PROD_SERVER}" \ + --bundle-out "artifacts/deploy/${ARTIFACT}.production.deployment.bundle.json" + + - name: Deploy exact target bundle + env: + ARTIFACT: ${{ matrix.artifact }} + run: | + semapact deployment deploy \ + --bundle "artifacts/deploy/${ARTIFACT}.production.deployment.bundle.json" \ + --warehouse-id "${DATABRICKS_WAREHOUSE_ID}" + + # Operational history is config-first via history.operational. From b49183c90cfd69bf07f27b9bae3cee74ae37f8e6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:43:48 +1000 Subject: [PATCH 195/616] test(examples): assert separated release and deployment pipelines --- tests/test_semapact_devops_examples_readable.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/test_semapact_devops_examples_readable.py b/tests/test_semapact_devops_examples_readable.py index fd3ede5b..5ac7a669 100644 --- a/tests/test_semapact_devops_examples_readable.py +++ b/tests/test_semapact_devops_examples_readable.py @@ -49,13 +49,16 @@ def test_data_product_github_example_uses_bundle_driven_ci_cd() -> None: encoding="utf-8" ) + assert "release assess" in workflow + assert "release approve" in workflow + assert "release finalize" in workflow assert "deployment assess" in workflow - assert "--release" in workflow + assert "--release-id" in workflow assert "deployment deploy" in workflow - assert "approval record" in workflow + assert "environment: contract-release" in workflow assert "environment: production" in workflow assert "--operational-history" not in workflow - assert "contract.release.bundle" not in workflow + assert "deployment assess \\\n --release" not in workflow def test_central_contract_repo_github_example_fans_out_and_commits_ledger_once() -> None: @@ -65,11 +68,13 @@ def test_central_contract_repo_github_example_fans_out_and_commits_ledger_once() assert "release classify-repo" in workflow assert "fromJSON(needs.detect-contracts.outputs.matrix)" in workflow + assert "release assess" in workflow + assert "release approve" in workflow + assert "release finalize" in workflow + assert "finalize-releases:" in workflow assert "deployment assess" in workflow - assert "--release" in workflow + assert "--release-id" in workflow assert "deployment deploy" in workflow - assert "approval record" in workflow - assert "commit-governance-ledger:" in workflow assert "merge-multiple: true" in workflow assert "--operational-history" not in workflow From 4c7ad76b05c1ab2704ab781dd71d7c079e4ce563 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:44:09 +1000 Subject: [PATCH 196/616] test(cli): verify release finalize materializes selected version --- tests/interfaces/test_release_workflow_cmd.py | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/interfaces/test_release_workflow_cmd.py diff --git a/tests/interfaces/test_release_workflow_cmd.py b/tests/interfaces/test_release_workflow_cmd.py new file mode 100644 index 00000000..60ad1d61 --- /dev/null +++ b/tests/interfaces/test_release_workflow_cmd.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from open_data_contract_standard.model import ( + OpenDataContractStandard, + SchemaObject, + SchemaProperty, +) + +from semapact.interfaces.commands.release_cmd import ( + run_release_approve, + run_release_assess, + run_release_finalize, +) +from semapact.platforms.git import GitWorkingTreeHistoryRepository +from semapact.utils.yaml_utils import dump_yaml + + +def _contract(*, include_created_at: bool = False) -> OpenDataContractStandard: + properties = [ + SchemaProperty( + name="id", + logicalType="string", + physicalType="varchar(255)", + required=True, + ) + ] + if include_created_at: + properties.append( + SchemaProperty( + name="created_at", + logicalType="timestamp", + physicalType="timestamp", + required=False, + ) + ) + return OpenDataContractStandard( + apiVersion="v3.1.0", + kind="DataContract", + id="orders-product", + name="Orders", + version="1.2.3", + status="active", + schema=[SchemaObject(name="orders", properties=properties)], + ) + + +def test_release_finalize_materializes_selected_version_and_history(tmp_path) -> None: + base = tmp_path / "base.yaml" + candidate = tmp_path / "contract.yaml" + bundle = tmp_path / "release.bundle.json" + released = tmp_path / "released.yaml" + dump_yaml(_contract(), base) + dump_yaml(_contract(include_created_at=True), candidate) + + run_release_assess( + SimpleNamespace( + base=str(base), + candidate=str(candidate), + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + effective_date="2026-09-20", + authority_reference=None, + runtime_context="auto", + bundle_out=str(bundle), + ) + ) + run_release_approve( + SimpleNamespace( + bundle=str(bundle), + actor_reference="github-environment:contract-release", + recorded_at="2026-09-20T10:00:00+10:00", + comment=None, + repository_root=str(tmp_path), + approval_out=None, + ) + ) + result = run_release_finalize( + SimpleNamespace( + bundle=str(bundle), + output_contract=str(released), + repository_root=str(tmp_path), + ) + ) + + materialized = OpenDataContractStandard.from_file(str(released)) + assert str(materialized.version) == "1.3.0" + + repository = GitWorkingTreeHistoryRepository(tmp_path) + record = repository.get_contract_release(result["contractReleaseId"]) + assert record.contract_version == "1.3.0" + assert record.released_contract_json From 7b38e035cd604ab04ba47aafc3c9d304673f19c8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:44:46 +1000 Subject: [PATCH 197/616] docs(readme): separate contract release from target deployment --- README.md | 92 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 59 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index d5d4e4b6..9064a4d2 100644 --- a/README.md +++ b/README.md @@ -184,48 +184,56 @@ SemaPact supports two version-authority modes: See [`docs/contractops_phases.md`](docs/contractops_phases.md) and [`docs/version_authority.md`](docs/version_authority.md). -### Governed runtime deployment +### Contract release and runtime deployment -SemaPact separates **candidate deployment** from **formal contract release**. +SemaPact treats formal contract release and runtime deployment as separate lifecycles. -The default path deploys an exact candidate revision without creating another semantic version: +A formal release is target-neutral: ```text -deployment assess -→ GovernanceDecision + ChangeSet -→ exact candidate DeploymentSourceSnapshot -→ DeploymentPlan + fresh CI preview -→ DeploymentBundle(release=false) +release assess +→ GovernanceDecision +→ ChangeSet +→ ReleasePlan +→ VersionResolution +→ ReleaseSnapshot +→ ReleaseBundle -deployment deploy -→ fresh preview → execute → fresh verify -→ IN_SYNC / DRIFT / INDETERMINATE +REVIEW → exact release approval + ↓ +release finalize +→ ContractReleaseRecord +→ materialize the selected version back to ODCS ``` -A formal release is explicit: +The selected version is calculated once. The resulting `ContractReleaseRecord` can then be deployed to any number of runtime targets without another version bump: -```bash -semapact deployment assess ... --release +```text +orders@1.4.0 +├── dev +├── test +└── prod ``` -Release mode adds: +Deployment is target-specific: ```text -ReleasePlan -→ VersionResolution -→ ReleaseSnapshot -→ DeploymentBundle(release=true) -→ exact approval when REVIEW -→ ContractReleaseRecord -``` +deployment assess --release-id +→ DeploymentSourceSnapshot +→ DeploymentPlan +→ fresh runtime preview +→ DeploymentBundle -A released version is environment-neutral. The same version can later be deployed to dev, test, and prod without another version bump. +deployment deploy +→ fresh preview → execute → fresh verify +→ IN_SYNC / DRIFT / INDETERMINATE +``` -`deploy` always re-observes runtime before execution; CI-time preview operations are never replayed blindly. +Candidate/non-release deployment remains available directly from base + candidate and does not create a new contract version or release history. Deployment telemetry is disabled by default. High-frequency execution history can be enabled once in typed `.semapact.yaml` configuration with a SQLite or Delta backend; it is not written into Git governance history. The `--operational-history` CLI option is only an override. -For Databricks, once a **formal release** is verified `IN_SYNC`, SemaPact projects release provenance to governed Unity Catalog tables using reserved tags: +For Databricks, once deployment of a finalized formal release verifies `IN_SYNC`, SemaPact projects the finalized release provenance to governed Unity Catalog tables using reserved tags: ```text semapact_contract_id @@ -375,29 +383,47 @@ semapact deployment assess \ --bundle-out ./artifacts/orders-dev.bundle.json ``` -Use `--release` only when that CI artifact is intended to create a new formal contract release/version: +Build a target-neutral formal release separately: ```bash -semapact deployment assess \ +semapact release assess \ --base ./contracts/orders.yaml \ --candidate ./contracts/orders.candidate.yaml \ --base-revision-ref git:abc123 \ --candidate-revision-ref git:def456 \ --effective-date 2026-09-20 \ - --server production \ - --release \ - --bundle-out ./artifacts/orders-prod.bundle.json + --bundle-out ./artifacts/orders.release.bundle.json +``` + +For REVIEW releases, record the exact external approval and then finalize the release: + +```bash +semapact release approve \ + --bundle ./artifacts/orders.release.bundle.json \ + --actor-reference github-environment:contract-release \ + --recorded-at 2026-09-20T10:00:00+10:00 + +semapact release finalize \ + --bundle ./artifacts/orders.release.bundle.json \ + --output-contract ./contracts/orders.yaml ``` -CD consumes the exact bundle in either mode: +Finalization writes the selected semantic version back to the ODCS contract and records the immutable `ContractReleaseRecord` in the Git governance ledger. + +Deployment then consumes that finalized release identity: ```bash +semapact deployment assess \ + --release-id \ + --server production \ + --bundle-out ./artifacts/orders-prod.deployment.bundle.json + semapact deployment deploy \ - --bundle ./artifacts/orders-prod.bundle.json \ + --bundle ./artifacts/orders-prod.deployment.bundle.json \ --warehouse-id ``` -Formal REVIEW releases resolve exact approval evidence from Git-backed history unless `--approval` is supplied explicitly. Candidate deployments do not require release approval. +Candidate deployments do not require release approval. Operational deployment history is configured project-wide rather than repeated on every deploy: From ed1bed8b9b97a96d002e91fe229f22a5abe2d717 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:45:10 +1000 Subject: [PATCH 198/616] docs(deployment): separate release and deployment handoff artifacts --- docs/deployment_plans.md | 190 ++++++++++++++++++++------------------- 1 file changed, 96 insertions(+), 94 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index ca998993..ee642f98 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -144,71 +144,84 @@ Preview, execution, and verification fail closed when fresh runtime evidence com The plan does not contain credentials, workspace clients, SQL connections, or provider sessions. -## CI assessment and the DeploymentBundle boundary +## ReleaseBundle and DeploymentBundle boundaries -Deployment and formal contract release are related but distinct lifecycles. +Formal contract release and runtime deployment are separate lifecycles with separate immutable handoff artifacts. -By default, `deployment assess` prepares a **candidate deployment**. It evaluates governance and freezes the exact candidate revision, but it does not calculate a new semantic version, create release approval evidence, or create release history: +A formal release is target-neutral: ```text base + candidate ↓ GovernanceDecision → ChangeSet -→ DeploymentSourceSnapshot(release=false) -→ DeploymentPlan - + -fresh runtime observation - ↓ -DeploymentPreview # review evidence only - ↓ -DeploymentBundle(release=false) +→ ReleasePlan +→ VersionResolution +→ ReleaseSnapshot +→ ReleaseBundle ``` -A formal release is explicit: +`ReleaseBundle` contains no runtime target, workspace, catalog, schema, warehouse, or deployment preview. It freezes exactly what is intended to become a released contract. -```bash -semapact deployment assess ... --release +For REVIEW decisions, approval binds: + +```text +PUBLISH ++ exact ReleaseSnapshot ID ++ exact ReleaseBundle digest ``` -Only that mode enters ContractOps release planning: +Finalization then creates the formal release fact and materializes the selected semantic version back into ODCS: ```text -base + candidate - ↓ -GovernanceDecision -→ ChangeSet -→ ReleasePlan -→ VersionResolution -→ ReleaseSnapshot -→ DeploymentSourceSnapshot(release=true) -→ DeploymentPlan - + -fresh runtime observation +ReleaseBundle ++ exact approval when REVIEW ↓ -DeploymentPreview +release finalize ↓ -DeploymentBundle(release=true) +ContractReleaseRecord ++ versioned ODCS contract ``` -The `release` flag is content-addressed inside the bundle. CD cannot reinterpret the same CI artifact as release or non-release later. - -A contract release is environment-neutral. One released version can subsequently be deployed to multiple targets without another version bump: +A finalized release is environment-neutral. One release can subsequently fan out to multiple runtime targets without another version bump: ```text -orders@1.4.0 +ContractRelease orders@1.4.0 ├── dev ├── test └── prod ``` -Promotion between targets is deployment lifecycle, not contract version lifecycle. +Deployment begins only after the release exists, or directly from an unreleased candidate for validation/test use. + +Candidate deployment: + +```text +base + candidate +→ GovernanceDecision + ChangeSet +→ DeploymentSourceSnapshot(release=false) +→ target-specific DeploymentPlan +→ fresh DeploymentPreview +→ DeploymentBundle +``` + +Finalized-release deployment: + +```text +ContractReleaseRecord +→ DeploymentSourceSnapshot(release=true) +→ target-specific DeploymentPlan +→ fresh DeploymentPreview +→ DeploymentBundle +``` + +A release-mode `DeploymentBundle` therefore carries the exact finalized `ContractReleaseRecord`, not `ReleasePlan`, `VersionResolution`, or `ReleaseSnapshot`. Deployment never creates or versions a contract release. ## CLI workflow -The public deployment workflow remains `assess → deploy`. `approve` is an optional explicit hook for custom/manual release workflows. +### Candidate deployment -### Candidate deployment — default +Candidate deployment remains a direct `assess → deploy` path: ```bash semapact deployment assess \ @@ -218,97 +231,88 @@ semapact deployment assess \ --candidate-revision-ref git:def456 \ --effective-date 2026-09-20 \ --server development \ - --bundle-out ./artifacts/orders-dev.bundle.json -``` + --bundle-out ./artifacts/orders-dev.deployment.bundle.json -This keeps the candidate contract's existing version unchanged. It does not create `ReleasePlan`, `VersionResolution`, `ReleaseSnapshot`, `ApprovalRecord`, or `ContractReleaseRecord`. +semapact deployment deploy \ + --bundle ./artifacts/orders-dev.deployment.bundle.json \ + --warehouse-id +``` -A non-release `GovernanceDecision(REVIEW)` may still be deployed for validation/test environments without release approval. `BLOCK` always fails closed. +This does not calculate another semantic version, create release approval, or write release history. REVIEW may proceed for candidate runtime validation; BLOCK always fails closed. ### Formal release -Add `--release` only when this workflow is intended to create a new formal contract version: +Build the target-neutral release artifact first: ```bash -semapact deployment assess \ +semapact release assess \ --base ./contracts/orders.yaml \ --candidate ./contracts/orders.candidate.yaml \ --base-revision-ref git:abc123 \ --candidate-revision-ref git:def456 \ --effective-date 2026-09-20 \ - --server production \ - --release \ - --bundle-out ./artifacts/orders-prod.bundle.json + --bundle-out ./artifacts/orders.release.bundle.json ``` -Release mode calculates the semantic version exactly once and embeds the resulting release artifacts in the immutable bundle. A REVIEW release requires exact approval evidence. An ALLOW release does not require human approval. - -### CI artifact handoff - -Both modes publish the exact bundle produced in CI. CD must download that artifact rather than rebuilding it from merged repository state. +For a REVIEW release, an external workflow may persist exact approval with the convenience command: -```text -CI - semapact deployment assess [...] - ↓ - immutable DeploymentBundle - ↓ - pipeline artifact storage - ↓ -CD - download exact bundle - ↓ - semapact deployment deploy --bundle ... +```bash +semapact release approve \ + --bundle ./artifacts/orders.release.bundle.json \ + --actor-reference github-environment:contract-release \ + --recorded-at 2026-09-20T10:00:00+10:00 ``` -For a formal release, the bundle also carries the exact version resolution that will become the release fact. For a candidate deployment, no release version is calculated. - -### Review evidence intake - -The normal CI/CD path does **not** require a human to run `semapact deployment approve`. +The approval is `PUBLISH`-scoped to the exact `ReleaseSnapshot` and `ReleaseBundle` digest. ALLOW releases do not require an ApprovalRecord. -For a formal REVIEW release, GitHub / Azure DevOps / GitLab owns the review interaction. A trusted integration records the provider event as an `ApprovalRecord` in Git-backed approval history. CD resolves the exact record automatically when `--approval` is omitted. +Finalize exactly once: -A usable release approval must match the exact: +```bash +semapact release finalize \ + --bundle ./artifacts/orders.release.bundle.json \ + --output-contract ./contracts/orders.yaml +``` -- `decisionId`; -- `changeSetId`; -- `releasePlanId`; -- `versionResolutionId`; -- `DEPLOY` operation; -- `deploymentPlanId`; -- `bundleDigest`. +Finalization: -Conflicting exact review actions fail closed. There is no hidden "latest approval wins" rule. +- authorizes the exact formal release; +- writes one immutable `ContractReleaseRecord` to the Git governance ledger; +- writes the selected version back to the ODCS contract file. -Custom/manual release workflows can still inject one directly: +After finalization, build any target-specific deployment bundle from the release identity: ```bash -semapact deployment deploy \ - --bundle ./artifacts/orders-prod.bundle.json \ - --approval ./artifacts/orders-prod.approval.json \ - --warehouse-id +semapact deployment assess \ + --release-id \ + --server production \ + --bundle-out ./artifacts/orders-prod.deployment.bundle.json ``` -Candidate deployments do not read or write release approval records. +The same `contract-release-id` may be assessed/deployed against dev, test, prod, or another runtime target. -### Deploy +### CI/CD handoff rules -Candidate CD: +Two immutable boundaries are now explicit: -```bash -semapact deployment deploy \ - --bundle ./artifacts/orders-dev.bundle.json \ - --warehouse-id +```text +CI release planning +→ ReleaseBundle +→ approval/finalize +→ ContractReleaseRecord + +runtime planning +ContractReleaseRecord + target +→ DeploymentBundle +→ fresh CD execution ``` -Formal release CD uses the same command. The release mode is already pinned inside the bundle. +CD must not rebuild either artifact from mutable repository state after its trust boundary. Deployment always re-observes runtime before mutation, so the CI-time preview remains review evidence only and is never replayed blindly. -At execution time SemaPact always re-observes runtime: +At execution time: ```text -exact bundle -→ authorize exact deployment source +exact DeploymentBundle +→ authorize exact candidate/finalized-release source → fresh runtime observation → fresh DeploymentPreview → execute @@ -316,8 +320,6 @@ exact bundle → IN_SYNC / DRIFT / INDETERMINATE ``` -The CI preview is review evidence only. It is never replayed as executable SQL. - ## Governance ledger vs operational history SemaPact deliberately separates low-frequency governance facts from high-frequency deployment telemetry. From bcc00936f49e7aff722e32bda0f02ead11dbbdbe Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:45:23 +1000 Subject: [PATCH 199/616] docs(deployment): align history and tag wording with finalized releases --- docs/deployment_plans.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index ee642f98..c16f2069 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -333,7 +333,7 @@ ApprovalRecord ContractReleaseRecord ``` -A formal `--release` deployment creates one immutable, target-neutral `ContractReleaseRecord` containing the released contract version, exact revision, and release-artifact identities. Target-specific bundle/approval evidence remains separate. A candidate deployment creates no release history. +`semapact release finalize` creates one immutable, target-neutral `ContractReleaseRecord` containing the released contract version, exact revision, and release-artifact identities. Target-specific deployment bundles remain separate. Candidate deployment creates no release history. The Git adapter writes deterministic history files under `.semapact/history/`. The surrounding GitOps workflow remains responsible for committing/publishing those files; SemaPact does not silently push repository branches. @@ -404,7 +404,7 @@ Rename, existing-column type/nullability mutation, unsafe required-column additi ### Unity Catalog release provenance tags -After a **formal release** reaches schema reconciliation status `IN_SYNC`, the Databricks adapter projects SemaPact-owned release provenance onto every governed Unity Catalog table: +After deployment of a **finalized formal release** reaches schema reconciliation status `IN_SYNC`, the Databricks adapter projects SemaPact-owned release provenance onto every governed Unity Catalog table: ```text semapact_contract_id From d1179e46007f19f9463cafaba10df046068fa9db Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:47:30 +1000 Subject: [PATCH 200/616] refactor(contractops): own finalized ContractRelease artifact --- semapact/contractops/execution_models.py | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/semapact/contractops/execution_models.py b/semapact/contractops/execution_models.py index 918fbcb2..09ecf07e 100644 --- a/semapact/contractops/execution_models.py +++ b/semapact/contractops/execution_models.py @@ -53,6 +53,59 @@ def to_contract(self) -> OpenDataContractStandard: return OpenDataContractStandard.model_validate_json(self.released_contract_json) +class ContractRelease(ContractOpsModel): + """Finalized target-neutral formal contract release.""" + + contract_release_id: str + contract_id: str + contract_version: str + decision_id: str + change_set_id: str + release_plan_id: str + version_resolution_id: str + release_snapshot_id: str + revision_ref: str + released_contract_json: str + + @field_validator( + "contract_release_id", + "contract_id", + "contract_version", + "decision_id", + "change_set_id", + "release_plan_id", + "version_resolution_id", + "release_snapshot_id", + "revision_ref", + "released_contract_json", + ) + @classmethod + def _require_contract_release_text(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("value must not be empty") + return cleaned + + @model_validator(mode="after") + def _validate_contract_snapshot(self) -> "ContractRelease": + contract = OpenDataContractStandard.model_validate_json( + self.released_contract_json + ) + if str(contract.id or "").strip() != self.contract_id: + raise ValueError("released contract ID does not match ContractRelease") + if str(contract.version or "").strip() != self.contract_version: + raise ValueError( + "released contract version does not match ContractRelease" + ) + return self + + def to_contract(self) -> OpenDataContractStandard: + """Materialize a fresh ODCS model from the finalized release.""" + return OpenDataContractStandard.model_validate_json( + self.released_contract_json + ) + + class AppliedContractRelease(ContractOpsModel): """Exact released ODCS state produced by an authorized APPLY operation.""" From 908e7275316278a6d79ed405184d320991fb053c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:47:43 +1000 Subject: [PATCH 201/616] refactor(contractops): own ContractRelease deterministic identity --- semapact/contractops/integrity.py | 55 +++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/semapact/contractops/integrity.py b/semapact/contractops/integrity.py index b400ea41..5c3ca4f4 100644 --- a/semapact/contractops/integrity.py +++ b/semapact/contractops/integrity.py @@ -15,6 +15,7 @@ from semapact.change_context import ChangeContext from semapact.contractops.execution_models import ( AppliedContractRelease, + ContractRelease, PublicationResult, ReleaseSnapshot, ) @@ -47,6 +48,9 @@ SEMAPACT_APPLIED_RELEASE_NAMESPACE = uuid.UUID( "a5de2e65-aee7-48ac-9cb6-6a785f4cdf33" ) +SEMAPACT_CONTRACT_RELEASE_NAMESPACE = uuid.UUID( + "0f95c8c5-4957-43f7-a38c-2756605c2df6" +) SEMAPACT_PUBLICATION_NAMESPACE = uuid.UUID( "f776fc77-b37f-43ef-bf6d-d8dcf38d264f" ) @@ -71,6 +75,8 @@ def validate_contractops_artifact_identity(artifact: object) -> None: validate_release_snapshot_identity(artifact) elif isinstance(artifact, AppliedContractRelease): validate_applied_release_identity(artifact) + elif isinstance(artifact, ContractRelease): + validate_contract_release_identity(artifact) elif isinstance(artifact, PublicationResult): validate_publication_result_identity(artifact) @@ -325,6 +331,55 @@ def validate_applied_release_identity(release: AppliedContractRelease) -> None: _require_identity(release.applied_release_id, expected, "AppliedContractRelease") +def compute_contract_release_id( + *, + contract_id: str, + contract_version: str, + decision_id: str, + change_set_id: str, + release_plan_id: str, + version_resolution_id: str, + release_snapshot_id: str, + revision_ref: str, + released_contract_json: str, +) -> str: + """Derive deterministic identity for one finalized formal contract release.""" + return deterministic_uuid5( + SEMAPACT_CONTRACT_RELEASE_NAMESPACE, + { + "contract_id": contract_id, + "contract_version": contract_version, + "decision_id": decision_id, + "change_set_id": change_set_id, + "release_plan_id": release_plan_id, + "version_resolution_id": version_resolution_id, + "release_snapshot_id": release_snapshot_id, + "revision_ref": revision_ref, + "released_contract_json": released_contract_json, + }, + ) + + +def validate_contract_release_identity(release: ContractRelease) -> None: + """Fail closed when finalized release identity/content diverge.""" + _require_canonical_json( + release.released_contract_json, + "ContractRelease snapshot", + ) + expected = compute_contract_release_id( + contract_id=release.contract_id, + contract_version=release.contract_version, + decision_id=release.decision_id, + change_set_id=release.change_set_id, + release_plan_id=release.release_plan_id, + version_resolution_id=release.version_resolution_id, + release_snapshot_id=release.release_snapshot_id, + revision_ref=release.revision_ref, + released_contract_json=release.released_contract_json, + ) + _require_identity(release.contract_release_id, expected, "ContractRelease") + + def compute_publication_id( *, applied_release_id: str, From 97dc2e7b6a70833d1ca9ef21c268abd9b78b56cd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:47:56 +1000 Subject: [PATCH 202/616] refactor(contractops): export finalized ContractRelease --- semapact/contractops/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/semapact/contractops/__init__.py b/semapact/contractops/__init__.py index 24658522..49163ed7 100644 --- a/semapact/contractops/__init__.py +++ b/semapact/contractops/__init__.py @@ -13,11 +13,13 @@ ) from semapact.contractops.execution_models import ( AppliedContractRelease, + ContractRelease, PublicationResult, ReleaseSnapshot, ) from semapact.contractops.integrity import ( validate_applied_release_identity, + validate_contract_release_identity, validate_release_snapshot_identity, validate_change_set_identity, validate_contractops_authorization_identity, @@ -45,6 +47,7 @@ __all__ = [ "AppliedContractRelease", + "ContractRelease", "ReleaseSnapshot", "AuthorizationReason", "ChangeSet", @@ -68,6 +71,7 @@ "publish_contract_release", "resolve_release_version", "validate_applied_release_identity", + "validate_contract_release_identity", "validate_release_snapshot_identity", "validate_change_set_identity", "validate_contractops_authorization_identity", From 146a31ea9d6cf958857e146d79f2edc1ed4f2ea8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:48:06 +1000 Subject: [PATCH 203/616] refactor(history): persist canonical ContractRelease without redefining it --- semapact/history/models.py | 49 +++----------------------------------- 1 file changed, 3 insertions(+), 46 deletions(-) diff --git a/semapact/history/models.py b/semapact/history/models.py index 594972d1..558ae500 100644 --- a/semapact/history/models.py +++ b/semapact/history/models.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from semapact.contractops.execution_models import ContractRelease from semapact.contractops.models import ReviewEvidenceAction, VersionAuthority from semapact.observation import ObservedPlatformState from semapact.reconciliation import ( @@ -140,52 +141,8 @@ def _validate_provenance_pairs(self) -> ReleaseRecord: return self -class ContractReleaseRecord(HistoryModel): - """Formal immutable contract release fact stored in the governance ledger.""" - - contract_release_id: str - contract_id: str - contract_version: str - decision_id: str - change_set_id: str - release_plan_id: str - version_resolution_id: str - release_snapshot_id: str - revision_ref: str - released_contract_json: str - - @field_validator( - "contract_release_id", - "contract_id", - "contract_version", - "decision_id", - "change_set_id", - "release_plan_id", - "version_resolution_id", - "release_snapshot_id", - "revision_ref", - "released_contract_json", - ) - @classmethod - def _require_contract_release_text(cls, value: str) -> str: - return _required_text(value) - - @model_validator(mode="after") - def _validate_contract_release_snapshot(self) -> "ContractReleaseRecord": - from open_data_contract_standard.model import OpenDataContractStandard - - contract = OpenDataContractStandard.model_validate_json( - self.released_contract_json - ) - if str(contract.id or "").strip() != self.contract_id: - raise ValueError("released contract ID does not match ContractReleaseRecord") - if str(contract.version or "").strip() != self.contract_version: - raise ValueError( - "released contract version does not match ContractReleaseRecord" - ) - return self - - +# Backward-compatible persistence name. Canonical ownership lives in ContractOps. +ContractReleaseRecord = ContractRelease class DeploymentStatus(str, Enum): """Terminal provider-execution outcome for one deployment occurrence.""" From 61d12deab50bfa573f344654143d76cfaf24d4fd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:48:17 +1000 Subject: [PATCH 204/616] refactor(history): delegate ContractRelease identity to ContractOps --- semapact/history/integrity.py | 55 +++++++---------------------------- 1 file changed, 11 insertions(+), 44 deletions(-) diff --git a/semapact/history/integrity.py b/semapact/history/integrity.py index 8c94654c..b39b0ac2 100644 --- a/semapact/history/integrity.py +++ b/semapact/history/integrity.py @@ -4,8 +4,12 @@ import uuid +from semapact.contractops import ContractRelease +from semapact.contractops.integrity import ( + compute_contract_release_id, + validate_contract_release_identity, +) from semapact.history.models import ( - ContractReleaseRecord, DeploymentRecord, ReleaseRecord, RuntimeObservationRecord, @@ -19,9 +23,6 @@ SEMAPACT_RELEASE_RECORD_NAMESPACE = uuid.UUID( "9f750d1c-8f0a-491a-b861-8e349fc351cb" ) -SEMAPACT_CONTRACT_RELEASE_RECORD_NAMESPACE = uuid.UUID( - "0f95c8c5-4957-43f7-a38c-2756605c2df6" -) SEMAPACT_DEPLOYMENT_RECORD_NAMESPACE = uuid.UUID( "93db6770-90f0-4ac5-a185-2f21e818d18e" ) @@ -102,51 +103,17 @@ def validate_release_record_identity(record: ReleaseRecord) -> None: def compute_contract_release_record_id( - *, - contract_id: str, - contract_version: str, - decision_id: str, - change_set_id: str, - release_plan_id: str, - version_resolution_id: str, - release_snapshot_id: str, - revision_ref: str, - released_contract_json: str, + **payload: object, ) -> str: - return deterministic_uuid5( - SEMAPACT_CONTRACT_RELEASE_RECORD_NAMESPACE, - { - "contract_id": contract_id, - "contract_version": contract_version, - "decision_id": decision_id, - "change_set_id": change_set_id, - "release_plan_id": release_plan_id, - "version_resolution_id": version_resolution_id, - "release_snapshot_id": release_snapshot_id, - "revision_ref": revision_ref, - "released_contract_json": released_contract_json, - }, - ) + """Compatibility wrapper around canonical ContractOps release identity.""" + return compute_contract_release_id(**payload) def validate_contract_release_record_identity( - record: ContractReleaseRecord, + record: ContractRelease, ) -> None: - expected = compute_contract_release_record_id( - contract_id=record.contract_id, - contract_version=record.contract_version, - decision_id=record.decision_id, - change_set_id=record.change_set_id, - release_plan_id=record.release_plan_id, - version_resolution_id=record.version_resolution_id, - release_snapshot_id=record.release_snapshot_id, - revision_ref=record.revision_ref, - released_contract_json=record.released_contract_json, - ) - if record.contract_release_id != expected: - raise ValueError( - "ContractReleaseRecord deterministic identity does not match content" - ) + """Compatibility wrapper around canonical ContractOps release validation.""" + validate_contract_release_identity(record) def compute_deployment_record_id( From 8bdf492692c59b81d0c4a8919aaf8dd2d20cb974 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:48:29 +1000 Subject: [PATCH 205/616] refactor(deployment): consume canonical ContractRelease source --- semapact/deployment/source.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/semapact/deployment/source.py b/semapact/deployment/source.py index 59707595..c888b7f9 100644 --- a/semapact/deployment/source.py +++ b/semapact/deployment/source.py @@ -7,8 +7,7 @@ from open_data_contract_standard.model import OpenDataContractStandard from pydantic import BaseModel, ConfigDict, field_validator, model_validator -from semapact.contractops import ReleaseSnapshot -from semapact.history.models import ContractReleaseRecord +from semapact.contractops import ContractRelease, ReleaseSnapshot from semapact.utils.deterministic import canonical_compact_json, deterministic_uuid5 @@ -147,12 +146,12 @@ def build_release_deployment_source( def build_contract_release_deployment_source( - release: ContractReleaseRecord, + release: ContractRelease, ) -> DeploymentSourceSnapshot: - """Project one finalized ContractReleaseRecord into deployment desired state.""" - if not isinstance(release, ContractReleaseRecord): + """Project one finalized ContractRelease into deployment desired state.""" + if not isinstance(release, ContractRelease): raise TypeError( - "release must be ContractReleaseRecord, " + "release must be ContractRelease, " f"got {type(release).__name__}" ) contract_json = release.released_contract_json From 20b0cac77166cd5391fae277120561e03c501d7a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:48:32 +1000 Subject: [PATCH 206/616] refactor(deployment): authorize canonical ContractRelease --- semapact/deployment/authorization.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index 4c13d1b9..ea85971a 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -16,8 +16,8 @@ validate_deployment_plan_identity, ) from semapact.deployment.source import DeploymentSourceSnapshot +from semapact.contractops import ContractRelease from semapact.governance import DecisionResult, GovernanceDecision -from semapact.history.models import ContractReleaseRecord from semapact.exceptions import ReleaseValidationError from semapact.governance.gate import GovernanceOperation @@ -154,7 +154,7 @@ def authorize_candidate_deployment( def authorize_contract_release_deployment( plan: DeploymentPlan, source: DeploymentSourceSnapshot, - release: ContractReleaseRecord, + release: ContractRelease, ) -> DeploymentAuthorization: """Authorize deployment from one already-finalized formal contract release.""" if not plan.release or not source.release: @@ -168,23 +168,23 @@ def authorize_contract_release_deployment( ) if source.release_id != release.contract_release_id: raise ReleaseValidationError( - "Deployment source does not reference the finalized ContractReleaseRecord" + "Deployment source does not reference the finalized ContractRelease" ) if source.contract_id != release.contract_id: raise ReleaseValidationError( - "Deployment source and ContractReleaseRecord contract IDs do not match" + "Deployment source and ContractRelease contract IDs do not match" ) if source.contract_version != release.contract_version: raise ReleaseValidationError( - "Deployment source and ContractReleaseRecord versions do not match" + "Deployment source and ContractRelease versions do not match" ) if source.revision_ref != release.revision_ref: raise ReleaseValidationError( - "Deployment source and ContractReleaseRecord revisions do not match" + "Deployment source and ContractRelease revisions do not match" ) if plan.release_id != release.contract_release_id: raise ReleaseValidationError( - "DeploymentPlan does not reference the finalized ContractReleaseRecord" + "DeploymentPlan does not reference the finalized ContractRelease" ) authorization_id = compute_deployment_authorization_id( From 0fe161e2ee23c9de5245e6ed15799accbb411943 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:48:36 +1000 Subject: [PATCH 207/616] refactor(deployment): bind bundle to canonical ContractRelease --- .../application/models/deployment_workflow.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py index 35f8b359..ddeefb40 100644 --- a/semapact/application/models/deployment_workflow.py +++ b/semapact/application/models/deployment_workflow.py @@ -7,14 +7,13 @@ from pydantic import BaseModel, ConfigDict, model_validator -from semapact.contractops import ChangeSet +from semapact.contractops import ChangeSet, ContractRelease from semapact.deployment import ( DeploymentPlan, DeploymentPreview, DeploymentSourceSnapshot, ) from semapact.governance import GovernanceDecision -from semapact.history.models import ContractReleaseRecord from semapact.reconciliation import ReconciliationResult, RuntimeDriftStatus from semapact.utils.deterministic import canonical_compact_json @@ -46,7 +45,7 @@ class DeploymentBundle(BaseModel): review_preview: DeploymentPreview decision: GovernanceDecision | None = None change_set: ChangeSet | None = None - contract_release: ContractReleaseRecord | None = None + contract_release: ContractRelease | None = None bundle_version: Literal["3"] = "3" @model_validator(mode="after") @@ -60,7 +59,7 @@ def _validate_bundle_links(self) -> "DeploymentBundle": if self.release: if self.contract_release is None: raise ValueError( - "Release DeploymentBundle requires finalized ContractReleaseRecord" + "Release DeploymentBundle requires finalized ContractRelease" ) if self.decision is not None or self.change_set is not None: raise ValueError( @@ -69,7 +68,7 @@ def _validate_bundle_links(self) -> "DeploymentBundle": release = self.contract_release if source.release_id != release.contract_release_id: raise ValueError( - "DeploymentBundle source does not reference ContractReleaseRecord" + "DeploymentBundle source does not reference ContractRelease" ) if source.contract_id != release.contract_id: raise ValueError( @@ -90,7 +89,7 @@ def _validate_bundle_links(self) -> "DeploymentBundle": ) if self.contract_release is not None: raise ValueError( - "Candidate DeploymentBundle cannot contain ContractReleaseRecord" + "Candidate DeploymentBundle cannot contain ContractRelease" ) if self.change_set.contract_id != self.decision.contract_id: raise ValueError( @@ -137,7 +136,7 @@ def build_deployment_bundle( review_preview: DeploymentPreview, decision: GovernanceDecision | None = None, change_set: ChangeSet | None = None, - contract_release: ContractReleaseRecord | None = None, + contract_release: ContractRelease | None = None, ) -> DeploymentBundle: """Package exact target-specific deployment material without release planning.""" digest = compute_deployment_bundle_digest( @@ -169,7 +168,7 @@ def compute_deployment_bundle_digest( review_preview: DeploymentPreview, decision: GovernanceDecision | None = None, change_set: ChangeSet | None = None, - contract_release: ContractReleaseRecord | None = None, + contract_release: ContractRelease | None = None, bundle_version: str = "3", ) -> str: """Return the immutable digest for one target-specific deployment bundle.""" From 88197d9cdbdcf2f35227f693d5e74f17097fe7fb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:48:39 +1000 Subject: [PATCH 208/616] refactor(deployment): depend on ContractOps release artifact --- semapact/application/services/deployment_workflow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index 93cc2117..2c88b65c 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -24,8 +24,8 @@ build_contract_release_deployment_source, ) from semapact.exceptions import ContractOpsAuthorizationError +from semapact.contractops import ContractRelease from semapact.history import ( - ContractReleaseRecord, OperationalHistorySink, build_operational_deployment_event, ) @@ -80,7 +80,7 @@ def assess( def assess_release( self, - release: ContractReleaseRecord, + release: ContractRelease, *, target: DeploymentTarget, adapter: DeploymentAdapter, From f70acd210e72ab0f4ce231fc4bcd3c8fcb636dc8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 17:48:49 +1000 Subject: [PATCH 209/616] refactor(release): persist canonical ContractRelease artifact --- .../application/services/contract_release_history.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/semapact/application/services/contract_release_history.py b/semapact/application/services/contract_release_history.py index be8a9ca7..33152193 100644 --- a/semapact/application/services/contract_release_history.py +++ b/semapact/application/services/contract_release_history.py @@ -4,9 +4,10 @@ from semapact.approval import ApprovalRecord from semapact.application.models.release import ReleaseBundle +from semapact.contractops import ContractRelease +from semapact.contractops.integrity import compute_contract_release_id from semapact.governance import DecisionResult, GovernanceOperation -from semapact.history import ContractReleaseHistoryRepository, ContractReleaseRecord -from semapact.history.integrity import compute_contract_release_record_id +from semapact.history import ContractReleaseHistoryRepository class ContractReleaseHistoryService: @@ -20,7 +21,7 @@ def record_release( bundle: ReleaseBundle, *, approval: ApprovalRecord | None, - ) -> ContractReleaseRecord: + ) -> ContractRelease: """Persist one finalized release, idempotently, after exact review validation.""" if bundle.decision.decision is DecisionResult.REVIEW: if approval is None: @@ -40,7 +41,7 @@ def record_release( snapshot = bundle.release_snapshot resolution = bundle.version_resolution - record_id = compute_contract_release_record_id( + record_id = compute_contract_release_id( contract_id=snapshot.contract_id, contract_version=snapshot.selected_version, decision_id=bundle.decision.decision_id, @@ -51,7 +52,7 @@ def record_release( revision_ref=snapshot.release_revision_ref, released_contract_json=snapshot.released_contract_json, ) - record = ContractReleaseRecord( + record = ContractRelease( contract_release_id=record_id, contract_id=snapshot.contract_id, contract_version=snapshot.selected_version, From 21fea5061a5dfa1578e79baa8d5475bf357e3b8a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:22:29 +1000 Subject: [PATCH 210/616] fix(deployment): allow finalized release authorization kind --- semapact/deployment/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index 37ebcee5..31390f90 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -318,7 +318,7 @@ class DeploymentAuthorization(DeploymentModel): deployment_plan_id: str source_snapshot_id: str allowed: bool = Field(strict=True) - authorization_kind: Literal["contractops", "governance"] = "contractops" + authorization_kind: Literal["contractops", "governance", "contract_release"] = "contractops" authorization_reference: str authorization_version: Literal["1", "2"] = "2" @@ -521,7 +521,7 @@ def compute_deployment_authorization_id( raise ValueError( f"Unsupported DeploymentAuthorization version: {authorization_version}" ) - if authorization_kind not in {"contractops", "governance"}: + if authorization_kind not in {"contractops", "governance", "contract_release"}: raise ValueError("Unsupported deployment authorization kind") source_id = _resolve_text_pair( source_snapshot_id, From 36bcf818bfc61a39ff430dfdfd427b5a7d88bf4f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:22:35 +1000 Subject: [PATCH 211/616] test(examples): avoid matching release-id as legacy release flag --- tests/test_semapact_devops_examples_readable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_semapact_devops_examples_readable.py b/tests/test_semapact_devops_examples_readable.py index 5ac7a669..430ec342 100644 --- a/tests/test_semapact_devops_examples_readable.py +++ b/tests/test_semapact_devops_examples_readable.py @@ -58,7 +58,7 @@ def test_data_product_github_example_uses_bundle_driven_ci_cd() -> None: assert "environment: contract-release" in workflow assert "environment: production" in workflow assert "--operational-history" not in workflow - assert "deployment assess \\\n --release" not in workflow + assert "\n --release \\" not in workflow def test_central_contract_repo_github_example_fans_out_and_commits_ledger_once() -> None: From 815d66f57a063bb1bd0ed711c5452bd402be2e5d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:23:00 +1000 Subject: [PATCH 212/616] refactor(release): name source revision provenance explicitly --- semapact/contractops/execution_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/contractops/execution_models.py b/semapact/contractops/execution_models.py index 09ecf07e..c22bcdf4 100644 --- a/semapact/contractops/execution_models.py +++ b/semapact/contractops/execution_models.py @@ -64,7 +64,7 @@ class ContractRelease(ContractOpsModel): release_plan_id: str version_resolution_id: str release_snapshot_id: str - revision_ref: str + source_revision_ref: str released_contract_json: str @field_validator( @@ -76,7 +76,7 @@ class ContractRelease(ContractOpsModel): "release_plan_id", "version_resolution_id", "release_snapshot_id", - "revision_ref", + "source_revision_ref", "released_contract_json", ) @classmethod From 8679e2126f1c9e3a3c3da93aa1bc4e03dfe63479 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:23:04 +1000 Subject: [PATCH 213/616] refactor(release): bind identity to source revision explicitly --- semapact/contractops/integrity.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/semapact/contractops/integrity.py b/semapact/contractops/integrity.py index 5c3ca4f4..ba302fd6 100644 --- a/semapact/contractops/integrity.py +++ b/semapact/contractops/integrity.py @@ -340,7 +340,7 @@ def compute_contract_release_id( release_plan_id: str, version_resolution_id: str, release_snapshot_id: str, - revision_ref: str, + source_revision_ref: str, released_contract_json: str, ) -> str: """Derive deterministic identity for one finalized formal contract release.""" @@ -354,7 +354,7 @@ def compute_contract_release_id( "release_plan_id": release_plan_id, "version_resolution_id": version_resolution_id, "release_snapshot_id": release_snapshot_id, - "revision_ref": revision_ref, + "source_revision_ref": source_revision_ref, "released_contract_json": released_contract_json, }, ) @@ -374,7 +374,7 @@ def validate_contract_release_identity(release: ContractRelease) -> None: release_plan_id=release.release_plan_id, version_resolution_id=release.version_resolution_id, release_snapshot_id=release.release_snapshot_id, - revision_ref=release.revision_ref, + source_revision_ref=release.source_revision_ref, released_contract_json=release.released_contract_json, ) _require_identity(release.contract_release_id, expected, "ContractRelease") From cf63226c867f2c3a09df197807a308385a600fd7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:23:07 +1000 Subject: [PATCH 214/616] refactor(release): persist source revision provenance --- semapact/application/services/contract_release_history.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/application/services/contract_release_history.py b/semapact/application/services/contract_release_history.py index 33152193..4ee64a43 100644 --- a/semapact/application/services/contract_release_history.py +++ b/semapact/application/services/contract_release_history.py @@ -49,7 +49,7 @@ def record_release( release_plan_id=bundle.release_plan.release_plan_id, version_resolution_id=resolution.version_resolution_id, release_snapshot_id=snapshot.release_snapshot_id, - revision_ref=snapshot.release_revision_ref, + source_revision_ref=snapshot.release_revision_ref, released_contract_json=snapshot.released_contract_json, ) record = ContractRelease( @@ -61,7 +61,7 @@ def record_release( release_plan_id=bundle.release_plan.release_plan_id, version_resolution_id=resolution.version_resolution_id, release_snapshot_id=snapshot.release_snapshot_id, - revision_ref=snapshot.release_revision_ref, + source_revision_ref=snapshot.release_revision_ref, released_contract_json=snapshot.released_contract_json, ) self._releases.put_contract_release(record) From d2938e63f659ca1754bcc994a85c38410edc6c17 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:23:27 +1000 Subject: [PATCH 215/616] refactor(deployment): consume release source revision provenance --- semapact/deployment/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/deployment/source.py b/semapact/deployment/source.py index c888b7f9..452a521a 100644 --- a/semapact/deployment/source.py +++ b/semapact/deployment/source.py @@ -157,7 +157,7 @@ def build_contract_release_deployment_source( contract_json = release.released_contract_json source_id = compute_deployment_source_id( contract_id=release.contract_id, - revision_ref=release.revision_ref, + revision_ref=release.source_revision_ref, contract_version=release.contract_version, contract_json=contract_json, release=True, @@ -167,7 +167,7 @@ def build_contract_release_deployment_source( return DeploymentSourceSnapshot( source_snapshot_id=source_id, contract_id=release.contract_id, - revision_ref=release.revision_ref, + revision_ref=release.source_revision_ref, contract_version=release.contract_version, contract_json=contract_json, release=True, From 9b09dbeab48fd21675d5d4118a8d8ed2834f5b1a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:23:31 +1000 Subject: [PATCH 216/616] refactor(deployment): name runtime release source revision explicitly --- semapact/deployment/adapters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/deployment/adapters.py b/semapact/deployment/adapters.py index 22096519..c3bcfb6a 100644 --- a/semapact/deployment/adapters.py +++ b/semapact/deployment/adapters.py @@ -24,7 +24,7 @@ class RuntimeReleaseMetadata: contract_id: str contract_version: str contract_release_id: str - revision_ref: str + source_revision_ref: str @runtime_checkable From 739617e94c50ce96d36181b98c9290ad5fb16e0a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:23:34 +1000 Subject: [PATCH 217/616] refactor(deployment): project source revision provenance --- semapact/application/services/deployment_workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index 2c88b65c..afae4fc8 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -164,7 +164,7 @@ def deploy( contract_id=release_record.contract_id, contract_version=release_record.contract_version, contract_release_id=release_record.contract_release_id, - revision_ref=release_record.revision_ref, + source_revision_ref=release_record.source_revision_ref, ), ) except Exception as exc: From e4c130c6b199b42717104bd678347569b373ecde Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:23:37 +1000 Subject: [PATCH 218/616] refactor(databricks): clarify release provenance tag semantics --- semapact/platforms/databricks/deployment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/platforms/databricks/deployment.py b/semapact/platforms/databricks/deployment.py index 7e021e77..876f5a45 100644 --- a/semapact/platforms/databricks/deployment.py +++ b/semapact/platforms/databricks/deployment.py @@ -181,7 +181,7 @@ def project_release_metadata( "semapact_contract_id": metadata.contract_id, "semapact_contract_version": metadata.contract_version, "semapact_release_id": metadata.contract_release_id, - "semapact_revision": metadata.revision_ref, + "semapact_source_revision": metadata.source_revision_ref, } rendered_tags = ", ".join( f"{_sql_string(key)} = {_sql_string(value)}" From b19b150eea1e93789a2eaf697e963799baa776ee Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:23:45 +1000 Subject: [PATCH 219/616] test(databricks): assert explicit source revision provenance --- tests/test_deployment_databricks.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_deployment_databricks.py b/tests/test_deployment_databricks.py index cb8aec4a..7c32770f 100644 --- a/tests/test_deployment_databricks.py +++ b/tests/test_deployment_databricks.py @@ -75,7 +75,7 @@ def _plan(*properties: SchemaProperty, source_reference: str = SOURCE_REFERENCE) applied_release_id="applied:test", contract_id="orders-product", release_plan_id="release-plan:test", - released_revision_ref="rev:released", + released_source_revision_ref="rev:released", selected_version="1.2.0", target=target, actions=(action,), @@ -85,7 +85,7 @@ def _plan(*properties: SchemaProperty, source_reference: str = SOURCE_REFERENCE) applied_release_id="applied:test", contract_id="orders-product", release_plan_id="release-plan:test", - released_revision_ref="rev:released", + released_source_revision_ref="rev:released", selected_version="1.2.0", target=target, actions=(action,), @@ -365,7 +365,7 @@ def test_release_metadata_projects_version_and_provenance_as_uc_tags() -> None: contract_id="orders-product", contract_version="1.2.0", contract_release_id="release-record-1", - revision_ref="rev:released", + source_revision_ref="rev:released", ), ) @@ -374,7 +374,7 @@ def test_release_metadata_projects_version_and_provenance_as_uc_tags() -> None: "('semapact_contract_id' = 'orders-product', " "'semapact_contract_version' = '1.2.0', " "'semapact_release_id' = 'release-record-1', " - "'semapact_revision' = 'rev:released')" + "'semapact_source_revision' = 'rev:released')" ] @@ -390,7 +390,7 @@ def test_release_metadata_projection_requires_warehouse() -> None: contract_id="orders-product", contract_version="1.2.0", contract_release_id="release-record-1", - revision_ref="rev:released", + source_revision_ref="rev:released", ), ) From f39800ec4d7001d1bdfcc07ac58786d7ca1ab3df Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:24:09 +1000 Subject: [PATCH 220/616] docs(release): clarify source revision provenance --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9064a4d2..30c1799b 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ For Databricks, once deployment of a finalized formal release verifies `IN_SYNC` semapact_contract_id semapact_contract_version semapact_release_id -semapact_revision +semapact_source_revision ``` Candidate deployments do not publish formal version/release tags. Business classifications or ABAC tags are not automatically mapped. @@ -408,7 +408,7 @@ semapact release finalize \ --output-contract ./contracts/orders.yaml ``` -Finalization writes the selected semantic version back to the ODCS contract and records the immutable `ContractReleaseRecord` in the Git governance ledger. +Finalization writes the selected semantic version back to the ODCS contract and records the immutable `ContractReleaseRecord` in the Git governance ledger. The release records the source revision from which it was derived; the finalized release identity is the immutable released-contract snapshot, not a claim that the source revision already contained the materialized version bump. Deployment then consumes that finalized release identity: From e1bac3f62128728d2a858a71e5e5e7751242d366 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:24:12 +1000 Subject: [PATCH 221/616] docs(deployment): distinguish source revision from release materialization --- docs/deployment_plans.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index c16f2069..7194a3b2 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -333,7 +333,7 @@ ApprovalRecord ContractReleaseRecord ``` -`semapact release finalize` creates one immutable, target-neutral `ContractReleaseRecord` containing the released contract version, exact revision, and release-artifact identities. Target-specific deployment bundles remain separate. Candidate deployment creates no release history. +`semapact release finalize` creates one immutable, target-neutral `ContractReleaseRecord` containing the released contract version, the exact source revision from which the release was derived, and release-artifact identities. The released contract snapshot itself is canonical; the source revision is provenance and need not already contain the materialized version bump. Target-specific deployment bundles remain separate. Candidate deployment creates no release history. The Git adapter writes deterministic history files under `.semapact/history/`. The surrounding GitOps workflow remains responsible for committing/publishing those files; SemaPact does not silently push repository branches. @@ -410,7 +410,7 @@ After deployment of a **finalized formal release** reaches schema reconciliation semapact_contract_id semapact_contract_version semapact_release_id -semapact_revision +semapact_source_revision ``` For example, a released `orders@1.4.0` table is tagged with version `1.4.0` and the exact `ContractReleaseRecord` identity. Candidate/non-release deployments never publish formal release/version tags. From 832dde4588f2a547e66726fe3e67382e6b6a9431 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:25:05 +1000 Subject: [PATCH 222/616] feat(release): expose source revision in finalize result --- semapact/interfaces/commands/release_cmd.py | 1 + 1 file changed, 1 insertion(+) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index 35c41e44..fdaeaac5 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -81,6 +81,7 @@ def run_release_finalize(args: argparse.Namespace) -> dict[str, Any]: "contractReleaseId": record.contract_release_id, "contractId": record.contract_id, "contractVersion": record.contract_version, + "sourceRevisionRef": record.source_revision_ref, "releaseBundleDigest": bundle.bundle_digest, "outputContract": str(output_path), } From da764eb3486552e959bc30ef0aa0919b75fcec6c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:25:08 +1000 Subject: [PATCH 223/616] test(release): assert finalized source revision provenance --- tests/test_release_workflow_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_release_workflow_service.py b/tests/test_release_workflow_service.py index b4d48550..57819684 100644 --- a/tests/test_release_workflow_service.py +++ b/tests/test_release_workflow_service.py @@ -120,6 +120,7 @@ def test_review_release_finalize_requires_exact_approval(tmp_path) -> None: assert record.contract_version == "1.3.0" assert record.release_snapshot_id == bundle.release_snapshot.release_snapshot_id + assert record.source_revision_ref == "git:candidate" assert ( repository.get_contract_release(record.contract_release_id) == record From d1018fd52379449d540c2b7472adf39b7ddc19b8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:25:11 +1000 Subject: [PATCH 224/616] test(cli): expose source revision on release finalization --- tests/interfaces/test_release_workflow_cmd.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/interfaces/test_release_workflow_cmd.py b/tests/interfaces/test_release_workflow_cmd.py index 60ad1d61..4694abb4 100644 --- a/tests/interfaces/test_release_workflow_cmd.py +++ b/tests/interfaces/test_release_workflow_cmd.py @@ -90,4 +90,6 @@ def test_release_finalize_materializes_selected_version_and_history(tmp_path) -> repository = GitWorkingTreeHistoryRepository(tmp_path) record = repository.get_contract_release(result["contractReleaseId"]) assert record.contract_version == "1.3.0" + assert record.source_revision_ref == "git:candidate" + assert result["sourceRevisionRef"] == "git:candidate" assert record.released_contract_json From e05a4f10a1866de01866c96816daef5bb7fe5b6d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:25:34 +1000 Subject: [PATCH 225/616] docs(examples): serialize Git-backed release finalization --- examples/github/data-product-ci-cd.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/github/data-product-ci-cd.yml b/examples/github/data-product-ci-cd.yml index d5cea487..b1a24476 100644 --- a/examples/github/data-product-ci-cd.yml +++ b/examples/github/data-product-ci-cd.yml @@ -13,6 +13,10 @@ on: permissions: contents: write +concurrency: + group: semapact-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: PYTHON_VERSION: "3.11" CONTRACT_PATH: contract.yaml From c47e6f0aa4969a6b2c390ad4f0b0d3d356956d54 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:25:37 +1000 Subject: [PATCH 226/616] docs(examples): serialize Git-backed release finalization --- examples/github/central-contract-repo-ci-cd.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/github/central-contract-repo-ci-cd.yml b/examples/github/central-contract-repo-ci-cd.yml index e9ad1035..b5f765ab 100644 --- a/examples/github/central-contract-repo-ci-cd.yml +++ b/examples/github/central-contract-repo-ci-cd.yml @@ -15,6 +15,10 @@ on: permissions: contents: write +concurrency: + group: semapact-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: PYTHON_VERSION: "3.11" CONTRACT_ROOT: contracts From 60fe34a94d00fee9218acaa4190ebba63bf017d5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:27:47 +1000 Subject: [PATCH 227/616] fix(test): preserve legacy released revision parameter --- tests/test_deployment_databricks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_deployment_databricks.py b/tests/test_deployment_databricks.py index 7c32770f..29451dd6 100644 --- a/tests/test_deployment_databricks.py +++ b/tests/test_deployment_databricks.py @@ -75,7 +75,7 @@ def _plan(*properties: SchemaProperty, source_reference: str = SOURCE_REFERENCE) applied_release_id="applied:test", contract_id="orders-product", release_plan_id="release-plan:test", - released_source_revision_ref="rev:released", + released_revision_ref="rev:released", selected_version="1.2.0", target=target, actions=(action,), @@ -85,7 +85,7 @@ def _plan(*properties: SchemaProperty, source_reference: str = SOURCE_REFERENCE) applied_release_id="applied:test", contract_id="orders-product", release_plan_id="release-plan:test", - released_source_revision_ref="rev:released", + released_revision_ref="rev:released", selected_version="1.2.0", target=target, actions=(action,), From 8ff7259a0c8725d05df72cfa7286f9b474132c37 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:28:51 +1000 Subject: [PATCH 228/616] fix(deployment): validate finalized release source revision --- semapact/application/models/deployment_workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py index ddeefb40..20e0e615 100644 --- a/semapact/application/models/deployment_workflow.py +++ b/semapact/application/models/deployment_workflow.py @@ -78,7 +78,7 @@ def _validate_bundle_links(self) -> "DeploymentBundle": raise ValueError( "DeploymentBundle source/release version mismatch" ) - if source.revision_ref != release.revision_ref: + if source.revision_ref != release.source_revision_ref: raise ValueError( "DeploymentBundle source/release revision mismatch" ) From 3054986a410570cbdd49a28ec71655373f9851a4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:29:23 +1000 Subject: [PATCH 229/616] docs(release): use canonical ContractRelease terminology --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 30c1799b..d2bbca7f 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ GovernanceDecision → ReleaseSnapshot → DeploymentPlan → DeploymentBundle - → ContractReleaseRecord + → ContractRelease ``` Release approval and runtime authorization remain explicit side-effect boundaries. @@ -202,11 +202,11 @@ release assess REVIEW → exact release approval ↓ release finalize -→ ContractReleaseRecord +→ ContractRelease → materialize the selected version back to ODCS ``` -The selected version is calculated once. The resulting `ContractReleaseRecord` can then be deployed to any number of runtime targets without another version bump: +The selected version is calculated once. The resulting `ContractRelease` can then be deployed to any number of runtime targets without another version bump: ```text orders@1.4.0 @@ -408,7 +408,7 @@ semapact release finalize \ --output-contract ./contracts/orders.yaml ``` -Finalization writes the selected semantic version back to the ODCS contract and records the immutable `ContractReleaseRecord` in the Git governance ledger. The release records the source revision from which it was derived; the finalized release identity is the immutable released-contract snapshot, not a claim that the source revision already contained the materialized version bump. +Finalization writes the selected semantic version back to the ODCS contract and records the immutable `ContractRelease` in the Git governance ledger. The release records the source revision from which it was derived; the finalized release identity is the immutable released-contract snapshot, not a claim that the source revision already contained the materialized version bump. Deployment then consumes that finalized release identity: From 6c8083769eebbebbd0043df829034b541dfab96d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:29:28 +1000 Subject: [PATCH 230/616] docs(release): use canonical ContractRelease terminology --- docs/deployment_plans.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 7194a3b2..200d8e11 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -179,7 +179,7 @@ ReleaseBundle ↓ release finalize ↓ -ContractReleaseRecord +ContractRelease + versioned ODCS contract ``` @@ -208,14 +208,14 @@ base + candidate Finalized-release deployment: ```text -ContractReleaseRecord +ContractRelease → DeploymentSourceSnapshot(release=true) → target-specific DeploymentPlan → fresh DeploymentPreview → DeploymentBundle ``` -A release-mode `DeploymentBundle` therefore carries the exact finalized `ContractReleaseRecord`, not `ReleasePlan`, `VersionResolution`, or `ReleaseSnapshot`. Deployment never creates or versions a contract release. +A release-mode `DeploymentBundle` therefore carries the exact finalized `ContractRelease`, not `ReleasePlan`, `VersionResolution`, or `ReleaseSnapshot`. Deployment never creates or versions a contract release. ## CLI workflow @@ -276,7 +276,7 @@ semapact release finalize \ Finalization: - authorizes the exact formal release; -- writes one immutable `ContractReleaseRecord` to the Git governance ledger; +- writes one immutable `ContractRelease` to the Git governance ledger; - writes the selected version back to the ODCS contract file. After finalization, build any target-specific deployment bundle from the release identity: @@ -298,10 +298,10 @@ Two immutable boundaries are now explicit: CI release planning → ReleaseBundle → approval/finalize -→ ContractReleaseRecord +→ ContractRelease runtime planning -ContractReleaseRecord + target +ContractRelease + target → DeploymentBundle → fresh CD execution ``` @@ -330,14 +330,14 @@ Git-backed history is appropriate for durable reviewable facts such as: ```text ApprovalRecord -ContractReleaseRecord +ContractRelease ``` -`semapact release finalize` creates one immutable, target-neutral `ContractReleaseRecord` containing the released contract version, the exact source revision from which the release was derived, and release-artifact identities. The released contract snapshot itself is canonical; the source revision is provenance and need not already contain the materialized version bump. Target-specific deployment bundles remain separate. Candidate deployment creates no release history. +`semapact release finalize` creates one immutable, target-neutral `ContractRelease` containing the released contract version, the exact source revision from which the release was derived, and release-artifact identities. The released contract snapshot itself is canonical; the source revision is provenance and need not already contain the materialized version bump. Target-specific deployment bundles remain separate. Candidate deployment creates no release history. The Git adapter writes deterministic history files under `.semapact/history/`. The surrounding GitOps workflow remains responsible for committing/publishing those files; SemaPact does not silently push repository branches. -The formal release fact is independent of runtime convergence. Once `ContractReleaseRecord` is created, a later runtime deployment failure does not undo or renumber the release. +The formal release fact is independent of runtime convergence. Once `ContractRelease` is created, a later runtime deployment failure does not undo or renumber the release. ### Operational history — opt in @@ -413,7 +413,7 @@ semapact_release_id semapact_source_revision ``` -For example, a released `orders@1.4.0` table is tagged with version `1.4.0` and the exact `ContractReleaseRecord` identity. Candidate/non-release deployments never publish formal release/version tags. +For example, a released `orders@1.4.0` table is tagged with version `1.4.0` and the exact `ContractRelease` identity. Candidate/non-release deployments never publish formal release/version tags. This projection is deliberately limited to SemaPact provenance. ODCS business tags, classifications, PII labels, or governed ABAC tags are **not** automatically mapped to Unity Catalog tags; those require a separate explicit mapping policy. From 51e8d9ae3b0368f2405e6317b287633ab17e7156 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:29:31 +1000 Subject: [PATCH 231/616] docs(release): use canonical ContractRelease terminology --- docs/contractops_phases.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contractops_phases.md b/docs/contractops_phases.md index 95a5e957..283fd7cd 100644 --- a/docs/contractops_phases.md +++ b/docs/contractops_phases.md @@ -30,7 +30,7 @@ PLAN RELEASE → ReleaseSnapshot FORMAL RELEASE -→ immutable ContractReleaseRecord +→ immutable ContractRelease DEPLOY → one or more target-specific DeploymentPlan / deployment occurrences @@ -202,7 +202,7 @@ DEPLOY mutates a runtime toward one exact target-specific `DeploymentPlan`. It i Candidate REVIEW deployments are allowed as non-release validation/test deployments without creating release approval evidence. `BLOCK` always fails closed. -For formal release mode, the new `ContractReleaseRecord` is the durable Git governance fact. Runtime deployment history is operational telemetry and is not written to Git by default. +For formal release mode, the new `ContractRelease` is the durable Git governance fact. Runtime deployment history is operational telemetry and is not written to Git by default. Provider-specific execution stays behind the deployment adapter. For Databricks, a formal release that reaches `IN_SYNC` also projects SemaPact-owned release/version provenance into Unity Catalog tags. From 99c3231cdb0f9d43ba8102de671b9e817524ae67 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 20:31:21 +1000 Subject: [PATCH 232/616] fix(deployment): authorize against release source revision --- semapact/deployment/authorization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index ea85971a..7bc72c58 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -178,7 +178,7 @@ def authorize_contract_release_deployment( raise ReleaseValidationError( "Deployment source and ContractRelease versions do not match" ) - if source.revision_ref != release.revision_ref: + if source.revision_ref != release.source_revision_ref: raise ReleaseValidationError( "Deployment source and ContractRelease revisions do not match" ) From 2f56e7b40d2900485d67d46a6a5c13628b3f3e03 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:16:03 +1000 Subject: [PATCH 233/616] refactor(deployment): make source provenance the mode discriminator --- semapact/deployment/source.py | 57 ++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/semapact/deployment/source.py b/semapact/deployment/source.py index 452a521a..0efbf877 100644 --- a/semapact/deployment/source.py +++ b/semapact/deployment/source.py @@ -3,6 +3,7 @@ from __future__ import annotations import uuid +from typing import Literal from open_data_contract_standard.model import OpenDataContractStandard from pydantic import BaseModel, ConfigDict, field_validator, model_validator @@ -15,18 +16,29 @@ "598e0c13-d9af-4cf9-a8a7-9fc7628e17c1" ) +DeploymentSourceKind = Literal[ + "candidate", + "release_snapshot", + "contract_release", +] + class DeploymentSourceSnapshot(BaseModel): - """Immutable desired state for deployment, with optional release provenance.""" + """Immutable desired state plus the provenance that produced it. + + source_kind is the single canonical discriminator for candidate versus + released desired state. Downstream plans/bundles derive release mode from this + source instead of persisting duplicate booleans. + """ model_config = ConfigDict(frozen=True, extra="forbid") source_snapshot_id: str + source_kind: DeploymentSourceKind contract_id: str revision_ref: str contract_version: str contract_json: str - release: bool release_id: str | None = None release_plan_id: str | None = None @@ -51,21 +63,23 @@ def _validate_source(self) -> "DeploymentSourceSnapshot": raise ValueError("DeploymentSourceSnapshot contract ID mismatch") if str(contract.version or "").strip() != self.contract_version: raise ValueError("DeploymentSourceSnapshot contract version mismatch") - if self.release: - if self.release_id is None or self.release_plan_id is None: + + if self.source_kind == "candidate": + if self.release_id is not None or self.release_plan_id is not None: raise ValueError( - "Release deployment source requires release provenance" + "Candidate deployment source cannot contain release provenance" ) - elif self.release_id is not None or self.release_plan_id is not None: + elif self.release_id is None or self.release_plan_id is None: raise ValueError( - "Non-release deployment source cannot contain release provenance" + "Released deployment source requires release provenance" ) + expected = compute_deployment_source_id( + source_kind=self.source_kind, contract_id=self.contract_id, revision_ref=self.revision_ref, contract_version=self.contract_version, contract_json=self.contract_json, - release=self.release, release_id=self.release_id, release_plan_id=self.release_plan_id, ) @@ -75,6 +89,15 @@ def _validate_source(self) -> "DeploymentSourceSnapshot": ) return self + @property + def is_release(self) -> bool: + return self.source_kind != "candidate" + + @property + def release(self) -> bool: + """Compatibility accessor; canonical state lives in source_kind.""" + return self.is_release + def to_contract(self) -> OpenDataContractStandard: return OpenDataContractStandard.model_validate_json(self.contract_json) @@ -101,45 +124,45 @@ def build_candidate_deployment_source( contract.model_dump(mode="json", by_alias=True, exclude_none=True) ) source_id = compute_deployment_source_id( + source_kind="candidate", contract_id=contract_id, revision_ref=revision, contract_version=contract_version, contract_json=contract_json, - release=False, release_id=None, release_plan_id=None, ) return DeploymentSourceSnapshot( source_snapshot_id=source_id, + source_kind="candidate", contract_id=contract_id, revision_ref=revision, contract_version=contract_version, contract_json=contract_json, - release=False, ) def build_release_deployment_source( snapshot: ReleaseSnapshot, ) -> DeploymentSourceSnapshot: - """Project one exact ReleaseSnapshot into the generic deployment source seam.""" + """Compatibility projection from a pre-finalization ReleaseSnapshot.""" contract_json = snapshot.released_contract_json source_id = compute_deployment_source_id( + source_kind="release_snapshot", contract_id=snapshot.contract_id, revision_ref=snapshot.release_revision_ref, contract_version=snapshot.selected_version, contract_json=contract_json, - release=True, release_id=snapshot.release_snapshot_id, release_plan_id=snapshot.release_plan_id, ) return DeploymentSourceSnapshot( source_snapshot_id=source_id, + source_kind="release_snapshot", contract_id=snapshot.contract_id, revision_ref=snapshot.release_revision_ref, contract_version=snapshot.selected_version, contract_json=contract_json, - release=True, release_id=snapshot.release_snapshot_id, release_plan_id=snapshot.release_plan_id, ) @@ -156,21 +179,21 @@ def build_contract_release_deployment_source( ) contract_json = release.released_contract_json source_id = compute_deployment_source_id( + source_kind="contract_release", contract_id=release.contract_id, revision_ref=release.source_revision_ref, contract_version=release.contract_version, contract_json=contract_json, - release=True, release_id=release.contract_release_id, release_plan_id=release.release_plan_id, ) return DeploymentSourceSnapshot( source_snapshot_id=source_id, + source_kind="contract_release", contract_id=release.contract_id, revision_ref=release.source_revision_ref, contract_version=release.contract_version, contract_json=contract_json, - release=True, release_id=release.contract_release_id, release_plan_id=release.release_plan_id, ) @@ -178,22 +201,22 @@ def build_contract_release_deployment_source( def compute_deployment_source_id( *, + source_kind: DeploymentSourceKind, contract_id: str, revision_ref: str, contract_version: str, contract_json: str, - release: bool, release_id: str | None, release_plan_id: str | None, ) -> str: return deterministic_uuid5( SEMAPACT_DEPLOYMENT_SOURCE_NAMESPACE, { + "source_kind": source_kind, "contract_id": contract_id, "revision_ref": revision_ref, "contract_version": contract_version, "contract_json": contract_json, - "release": release, "release_id": release_id, "release_plan_id": release_plan_id, }, From 1099768657d2d51287ef977f1ef1ff44422662b1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:16:27 +1000 Subject: [PATCH 234/616] refactor(deployment): remove duplicate release mode from plans --- semapact/deployment/models.py | 90 ++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 32 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index 31390f90..7b003da8 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -135,10 +135,9 @@ class DeploymentPlan(DeploymentModel): contract_version: str target: DeploymentTarget actions: tuple[DeploymentAction, ...] - release: bool = Field(strict=True) release_id: str | None = None release_plan_id: str | None = None - plan_version: Literal["2", "3", "4"] = "4" + plan_version: Literal["2", "3", "4", "5"] = "5" @model_validator(mode="before") @classmethod @@ -161,6 +160,16 @@ def _upgrade_legacy_plan_payload(cls, value): payload["plan_version"] = version else: version = str(raw_version) + if version == "4": + legacy_release = payload.pop("release", None) + has_release_provenance = bool( + payload.get("release_id") or payload.get("release_plan_id") + ) + if legacy_release is not None and bool(legacy_release) != has_release_provenance: + raise ValueError( + "Legacy DeploymentPlan release flag conflicts with release provenance" + ) + return payload if version not in {"2", "3"}: return payload @@ -170,7 +179,6 @@ def _upgrade_legacy_plan_payload(cls, value): payload.setdefault("source_snapshot_id", legacy_release_id) payload.setdefault("revision_ref", payload.get("released_revision_ref")) payload.setdefault("contract_version", payload.get("selected_version")) - payload.setdefault("release", True) payload.setdefault("release_id", legacy_release_id) payload.pop("applied_release_id", None) payload.pop("released_revision_ref", None) @@ -207,14 +215,11 @@ def _validate_action_order_and_identity(self) -> "DeploymentPlan": if len(governed_assets) != len(set(governed_assets)): raise ValueError("DeploymentPlan cannot contain duplicate governed assets") - if self.release: - if self.release_id is None or self.release_plan_id is None: - raise ValueError( - "Release DeploymentPlan requires release_id and release_plan_id" - ) - elif self.release_id is not None or self.release_plan_id is not None: + has_release_id = self.release_id is not None + has_release_plan = self.release_plan_id is not None + if has_release_id != has_release_plan: raise ValueError( - "Non-release DeploymentPlan must not contain release provenance" + "DeploymentPlan release_id and release_plan_id must be provided together" ) validate_deployment_plan_identity(self) @@ -223,17 +228,28 @@ def _validate_action_order_and_identity(self) -> "DeploymentPlan": @model_serializer(mode="wrap") def _serialize_legacy_plan(self, handler): payload = handler(self) + if self.plan_version == "4": + payload["release"] = self.is_release + return payload if self.plan_version not in {"2", "3"}: return payload payload["released_revision_ref"] = payload.pop("revision_ref") payload["selected_version"] = payload.pop("contract_version") payload.pop("source_snapshot_id", None) - payload.pop("release", None) if self.plan_version == "2": payload["applied_release_id"] = payload.pop("release_id") return payload + @property + def is_release(self) -> bool: + return self.release_id is not None + + @property + def release(self) -> bool: + """Compatibility accessor; canonical state is release provenance.""" + return self.is_release + @property def applied_release_id(self) -> str: """Compatibility alias for callers using the v2 release field.""" @@ -397,7 +413,7 @@ def compute_deployment_plan_id( contract_version: str | None = None, selected_version: str | None = None, release_plan_id: str | None = None, - release: bool = True, + release: bool | None = None, target: DeploymentTarget, actions: Sequence[DeploymentAction], plan_version: str | None = None, @@ -405,8 +421,8 @@ def compute_deployment_plan_id( if plan_version is None: if applied_release_id is not None: plan_version = "2" - elif source_snapshot_id is not None and release is False: - plan_version = "4" + elif source_snapshot_id is not None: + plan_version = "5" else: plan_version = "3" @@ -453,27 +469,37 @@ def compute_deployment_plan_id( }, ) - if plan_version != "4": + if plan_version not in {"4", "5"}: raise ValueError(f"Unsupported DeploymentPlan version: {plan_version}") - if release and (release_id is None or release_plan_id is None): - raise ValueError("Release DeploymentPlan requires release provenance") - if not release and (release_id is not None or release_plan_id is not None): - raise ValueError("Non-release DeploymentPlan cannot contain release provenance") + has_release_id = release_id is not None + has_release_plan = release_plan_id is not None + if has_release_id != has_release_plan: + raise ValueError( + "DeploymentPlan release_id and release_plan_id must be provided together" + ) + derived_release = has_release_id + if plan_version == "4" and release is not None and bool(release) != derived_release: + raise ValueError( + "Legacy DeploymentPlan release flag conflicts with release provenance" + ) + + payload = { + "source_snapshot_id": resolved_source_id, + "contract_id": contract_id, + "revision_ref": resolved_revision, + "contract_version": resolved_version, + "release_id": release_id, + "release_plan_id": release_plan_id, + "target": target.model_dump(mode="json"), + "actions": [action.model_dump(mode="json") for action in actions], + "plan_version": plan_version, + } + if plan_version == "4": + payload["release"] = derived_release return deterministic_uuid5( SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, - { - "source_snapshot_id": resolved_source_id, - "contract_id": contract_id, - "revision_ref": resolved_revision, - "contract_version": resolved_version, - "release": release, - "release_id": release_id, - "release_plan_id": release_plan_id, - "target": target.model_dump(mode="json"), - "actions": [action.model_dump(mode="json") for action in actions], - "plan_version": plan_version, - }, + payload, ) @@ -612,7 +638,7 @@ def validate_deployment_plan_identity(plan: DeploymentPlan) -> None: release_plan_id=plan.release_plan_id, revision_ref=plan.revision_ref, contract_version=plan.contract_version, - release=plan.release, + release=plan.is_release if plan.plan_version == "4" else None, target=plan.target, actions=plan.actions, plan_version=plan.plan_version, From 218bba348f3795b02e69a02d32da43112e91d74e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:16:37 +1000 Subject: [PATCH 235/616] refactor(deployment): build canonical v5 plans from source provenance --- semapact/deployment/planner.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/semapact/deployment/planner.py b/semapact/deployment/planner.py index 094e969d..ede3d70c 100644 --- a/semapact/deployment/planner.py +++ b/semapact/deployment/planner.py @@ -82,7 +82,7 @@ def build_deployment_plan_from_source( source: DeploymentSourceSnapshot, target: DeploymentTarget, ) -> DeploymentPlan: - """Build a v4 plan from an exact candidate or release deployment source.""" + """Build a v5 plan from one exact deployment source snapshot.""" if not isinstance(source, DeploymentSourceSnapshot): raise TypeError( "source must be DeploymentSourceSnapshot, " @@ -100,12 +100,11 @@ def build_deployment_plan_from_source( contract_id=source.contract_id, revision_ref=source.revision_ref, contract_version=source.contract_version, - release=source.release, release_id=source.release_id, release_plan_id=source.release_plan_id, target=target, actions=ordered_actions, - plan_version="4", + plan_version="5", ) return DeploymentPlan( deployment_plan_id=deployment_plan_id, @@ -115,10 +114,9 @@ def build_deployment_plan_from_source( contract_version=source.contract_version, target=target, actions=ordered_actions, - release=source.release, release_id=source.release_id, release_plan_id=source.release_plan_id, - plan_version="4", + plan_version="5", ) def build_deployment_actions( From 9d2a47da2db0670727a43b0487b0636a8af76ddd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:16:54 +1000 Subject: [PATCH 236/616] refactor(deployment): derive bundle mode from source provenance --- .../application/models/deployment_workflow.py | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py index 20e0e615..1bbbc810 100644 --- a/semapact/application/models/deployment_workflow.py +++ b/semapact/application/models/deployment_workflow.py @@ -25,7 +25,6 @@ class DeploymentExecutionResult(BaseModel): bundle_digest: str deployment_plan_id: str - authorization_id: str contract_release_id: str | None = None fresh_preview: DeploymentPreview reconciliation: ReconciliationResult @@ -34,29 +33,38 @@ class DeploymentExecutionResult(BaseModel): class DeploymentBundle(BaseModel): - """Immutable target-specific CI-to-CD deployment package.""" + """Immutable target-specific CI-to-CD deployment package. + + Candidate versus formal-release mode is derived from deployment_source. + The bundle never stores a second mode flag. + """ model_config = ConfigDict(frozen=True, extra="forbid") bundle_digest: str - release: bool deployment_source: DeploymentSourceSnapshot deployment_plan: DeploymentPlan review_preview: DeploymentPreview decision: GovernanceDecision | None = None change_set: ChangeSet | None = None contract_release: ContractRelease | None = None - bundle_version: Literal["3"] = "3" + bundle_version: Literal["4"] = "4" + + @property + def is_release(self) -> bool: + return self.deployment_source.source_kind == "contract_release" + + @property + def release(self) -> bool: + """Compatibility accessor; canonical mode lives in deployment_source.""" + return self.is_release @model_validator(mode="after") def _validate_bundle_links(self) -> "DeploymentBundle": source = self.deployment_source plan = self.deployment_plan - if source.release is not self.release or plan.release is not self.release: - raise ValueError("DeploymentBundle release mode mismatch") - - if self.release: + if self.is_release: if self.contract_release is None: raise ValueError( "Release DeploymentBundle requires finalized ContractRelease" @@ -83,6 +91,10 @@ def _validate_bundle_links(self) -> "DeploymentBundle": "DeploymentBundle source/release revision mismatch" ) else: + if source.source_kind != "candidate": + raise ValueError( + "Canonical DeploymentBundle supports candidate or finalized ContractRelease sources" + ) if self.decision is None or self.change_set is None: raise ValueError( "Candidate DeploymentBundle requires decision and ChangeSet" @@ -110,11 +122,16 @@ def _validate_bundle_links(self) -> "DeploymentBundle": raise ValueError("DeploymentBundle plan/source revision mismatch") if plan.contract_version != source.contract_version: raise ValueError("DeploymentBundle plan/source version mismatch") + if plan.release_id != source.release_id: + raise ValueError("DeploymentBundle plan/source release provenance mismatch") + if plan.release_plan_id != source.release_plan_id: + raise ValueError( + "DeploymentBundle plan/source release-plan provenance mismatch" + ) if self.review_preview.deployment_plan_id != plan.deployment_plan_id: raise ValueError("DeploymentBundle preview does not match DeploymentPlan") expected = compute_deployment_bundle_digest( - release=self.release, deployment_source=source, deployment_plan=plan, review_preview=self.review_preview, @@ -130,7 +147,6 @@ def _validate_bundle_links(self) -> "DeploymentBundle": def build_deployment_bundle( *, - release: bool, deployment_source: DeploymentSourceSnapshot, deployment_plan: DeploymentPlan, review_preview: DeploymentPreview, @@ -140,7 +156,6 @@ def build_deployment_bundle( ) -> DeploymentBundle: """Package exact target-specific deployment material without release planning.""" digest = compute_deployment_bundle_digest( - release=release, deployment_source=deployment_source, deployment_plan=deployment_plan, review_preview=review_preview, @@ -150,7 +165,6 @@ def build_deployment_bundle( ) return DeploymentBundle( bundle_digest=digest, - release=release, deployment_source=deployment_source, deployment_plan=deployment_plan, review_preview=review_preview, @@ -162,19 +176,17 @@ def build_deployment_bundle( def compute_deployment_bundle_digest( *, - release: bool, deployment_source: DeploymentSourceSnapshot, deployment_plan: DeploymentPlan, review_preview: DeploymentPreview, decision: GovernanceDecision | None = None, change_set: ChangeSet | None = None, contract_release: ContractRelease | None = None, - bundle_version: str = "3", + bundle_version: str = "4", ) -> str: """Return the immutable digest for one target-specific deployment bundle.""" payload = { "bundle_version": bundle_version, - "release": release, "deployment_source": deployment_source.model_dump(mode="json"), "deployment_plan": deployment_plan.model_dump(mode="json"), "review_preview": review_preview.model_dump(mode="json"), From f3c42442585c9eb32d883edc66a414fb0397bf8d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:17:04 +1000 Subject: [PATCH 237/616] refactor(deployment): separate runtime apply from legacy authorization --- semapact/deployment/adapters.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/semapact/deployment/adapters.py b/semapact/deployment/adapters.py index c3bcfb6a..d4f69d21 100644 --- a/semapact/deployment/adapters.py +++ b/semapact/deployment/adapters.py @@ -57,6 +57,15 @@ def verify(self, plan: DeploymentPlan) -> ReconciliationResult: """Observe runtime and verify convergence for one exact plan.""" raise NotImplementedError + @abstractmethod + def apply( + self, + plan: DeploymentPlan, + preview: DeploymentPreview, + ) -> None: + """Apply one exact preview after the application boundary authorizes execution.""" + raise NotImplementedError + @abstractmethod def execute( self, @@ -64,5 +73,5 @@ def execute( preview: DeploymentPreview, authorization: DeploymentAuthorization, ) -> None: - """Execute only the exact authorized preview.""" + """Compatibility entrypoint for legacy in-process authorization artifacts.""" raise NotImplementedError From bbeca8d5ef50b24f9ea7db0b9d43baccef8f63fb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:17:38 +1000 Subject: [PATCH 238/616] refactor(deployment): keep authorization outside runtime apply --- semapact/deployment/orchestrator.py | 47 ++++++++++++++++++----------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/semapact/deployment/orchestrator.py b/semapact/deployment/orchestrator.py index f315fbb5..ecf63932 100644 --- a/semapact/deployment/orchestrator.py +++ b/semapact/deployment/orchestrator.py @@ -94,29 +94,20 @@ def verify(self, plan: DeploymentPlan) -> ReconciliationResult: schema_mapper=self._schema_mapper, ) - def execute( + def apply( self, plan: DeploymentPlan, preview: DeploymentPreview, - authorization: DeploymentAuthorization, ) -> None: - """Execute only the exact authorized preview against unchanged runtime state.""" + """Apply the exact preview against unchanged runtime state. + + The application/CI boundary decides whether execution may run. This adapter + only enforces plan/preview/runtime integrity and never interprets release + facts as deployment authorization. + """ validate_deployment_preview_identity(preview) - validate_deployment_authorization_identity(authorization) desired_by_action = self._validate_and_map_plan(plan) - if not authorization.allowed: - raise ContractOpsAuthorizationError( - "DeploymentAuthorization is not allowed" - ) - if authorization.deployment_plan_id != plan.deployment_plan_id: - raise ContractOpsAuthorizationError( - "DeploymentAuthorization is not bound to this DeploymentPlan" - ) - if authorization.source_snapshot_id != plan.source_snapshot_id: - raise ContractOpsAuthorizationError( - "DeploymentAuthorization source does not match DeploymentPlan" - ) if preview.deployment_plan_id != plan.deployment_plan_id: raise ValidationError( "DeploymentPreview is not bound to this DeploymentPlan" @@ -154,7 +145,7 @@ def execute( if expected != preview: raise ValidationError( "DeploymentPreview no longer equals the deterministic preview for " - "the authorized plan and runtime evidence" + "the plan and runtime evidence" ) for operation in preview.operations: @@ -162,6 +153,28 @@ def execute( continue self._executor.execute(operation) + def execute( + self, + plan: DeploymentPlan, + preview: DeploymentPreview, + authorization: DeploymentAuthorization, + ) -> None: + """Compatibility wrapper for legacy in-process DeploymentAuthorization.""" + validate_deployment_authorization_identity(authorization) + if not authorization.allowed: + raise ContractOpsAuthorizationError( + "DeploymentAuthorization is not allowed" + ) + if authorization.deployment_plan_id != plan.deployment_plan_id: + raise ContractOpsAuthorizationError( + "DeploymentAuthorization is not bound to this DeploymentPlan" + ) + if authorization.source_snapshot_id != plan.source_snapshot_id: + raise ContractOpsAuthorizationError( + "DeploymentAuthorization source does not match DeploymentPlan" + ) + self.apply(plan, preview) + def _preview_from_observation( self, plan: DeploymentPlan, From f580e3f64f6383183968e08feec2d3f93be77d98 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:17:47 +1000 Subject: [PATCH 239/616] refactor(deployment): expose authorization-free canonical apply --- semapact/application/services/deployment.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/semapact/application/services/deployment.py b/semapact/application/services/deployment.py index e81b6c2e..765940df 100644 --- a/semapact/application/services/deployment.py +++ b/semapact/application/services/deployment.py @@ -38,6 +38,17 @@ def preview( _validate_component_key(adapter.key, plan.target.platform, "deployment adapter") return adapter.preview(plan) + def apply( + self, + plan: DeploymentPlan, + preview: DeploymentPreview, + *, + adapter: DeploymentAdapter, + ) -> None: + """Apply one exact preview after the external execution boundary allows CD.""" + _validate_component_key(adapter.key, plan.target.platform, "deployment adapter") + adapter.apply(plan, preview) + def execute( self, plan: DeploymentPlan, From 1d7cbb4aae1668f1e8071f2066ef3f0ef9d17eb9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:18:07 +1000 Subject: [PATCH 240/616] refactor(deployment): separate release validation from authorization --- semapact/deployment/authorization.py | 52 ++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index 7bc72c58..69c1b4e3 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -104,6 +104,33 @@ def authorize_deployment( +def validate_candidate_deployment_context( + plan: DeploymentPlan, + source: DeploymentSourceSnapshot, + decision: GovernanceDecision, +) -> None: + """Validate candidate deployment provenance and fail closed on BLOCK.""" + if source.source_kind != "candidate" or plan.is_release: + raise ReleaseValidationError( + "Candidate deployment requires candidate source without release provenance" + ) + validate_deployment_plan_identity(plan) + if plan.source_snapshot_id != source.source_snapshot_id: + raise ReleaseValidationError( + "DeploymentPlan does not reference the supplied candidate source" + ) + if plan.contract_id != source.contract_id: + raise ReleaseValidationError( + "DeploymentPlan and candidate source contract IDs do not match" + ) + if decision.contract_id != source.contract_id: + raise ReleaseValidationError( + "GovernanceDecision does not match candidate deployment contract" + ) + if decision.decision is DecisionResult.BLOCK: + raise ReleaseValidationError("Candidate deployment is blocked by governance") + + def authorize_candidate_deployment( plan: DeploymentPlan, source: DeploymentSourceSnapshot, @@ -114,7 +141,7 @@ def authorize_candidate_deployment( Candidate deployment never creates release approval evidence. BLOCK remains fail-closed; ALLOW and REVIEW may proceed as non-release runtime validation. """ - if plan.release or source.release: + if plan.is_release or source.source_kind != "candidate": raise ReleaseValidationError( "Candidate deployment authorization requires non-release plan/source" ) @@ -151,15 +178,15 @@ def authorize_candidate_deployment( ) -def authorize_contract_release_deployment( +def validate_contract_release_deployment_context( plan: DeploymentPlan, source: DeploymentSourceSnapshot, release: ContractRelease, -) -> DeploymentAuthorization: - """Authorize deployment from one already-finalized formal contract release.""" - if not plan.release or not source.release: +) -> None: + """Validate that one plan is derived from the exact finalized release.""" + if source.source_kind != "contract_release" or not plan.is_release: raise ReleaseValidationError( - "Finalized release deployment requires release plan/source" + "Finalized release deployment requires ContractRelease source provenance" ) validate_deployment_plan_identity(plan) if plan.source_snapshot_id != source.source_snapshot_id: @@ -187,6 +214,19 @@ def authorize_contract_release_deployment( "DeploymentPlan does not reference the finalized ContractRelease" ) + +def authorize_contract_release_deployment( + plan: DeploymentPlan, + source: DeploymentSourceSnapshot, + release: ContractRelease, +) -> DeploymentAuthorization: + """Compatibility adapter for callers that still require an authorization artifact. + + Canonical CI/CD validates the release context and treats the protected execution + boundary as deployment authority; it does not interpret ContractRelease itself as + authorization. + """ + validate_contract_release_deployment_context(plan, source, release) authorization_id = compute_deployment_authorization_id( deployment_plan_id=plan.deployment_plan_id, source_snapshot_id=source.source_snapshot_id, From 1786631e1e8179e13a5820bb3219c21b38ebc672 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:18:17 +1000 Subject: [PATCH 241/616] refactor(deployment): export canonical context validators --- semapact/deployment/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index 00a37628..e662fea3 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -9,6 +9,8 @@ authorize_candidate_deployment, authorize_contract_release_deployment, authorize_deployment, + validate_candidate_deployment_context, + validate_contract_release_deployment_context, ) from semapact.deployment.compilers import TransitionCompiler from semapact.deployment.models import ( @@ -64,6 +66,8 @@ "authorize_candidate_deployment", "authorize_contract_release_deployment", "authorize_deployment", + "validate_candidate_deployment_context", + "validate_contract_release_deployment_context", "build_deployment_actions", "build_deployment_plan", "build_deployment_plan_from_source", From dddbe2a20217740ff0822e1b1985c5fb3b2ed5bf Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:18:42 +1000 Subject: [PATCH 242/616] refactor(deployment): keep deploy authority at external execution boundary --- .../services/deployment_workflow.py | 54 ++++++++----------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index afae4fc8..3792d210 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -13,18 +13,17 @@ ) from semapact.application.services.deployment import DeploymentService from semapact.application.services.governance import GovernanceService +from semapact.contractops import ContractRelease from semapact.deployment import ( DeploymentAdapter, DeploymentTarget, RuntimeReleaseMetadata, RuntimeReleaseMetadataProjector, - authorize_candidate_deployment, - authorize_contract_release_deployment, build_candidate_deployment_source, build_contract_release_deployment_source, + validate_candidate_deployment_context, + validate_contract_release_deployment_context, ) -from semapact.exceptions import ContractOpsAuthorizationError -from semapact.contractops import ContractRelease from semapact.history import ( OperationalHistorySink, build_operational_deployment_event, @@ -33,7 +32,7 @@ class DeploymentWorkflowService: - """Assess candidate/finalized-release desired state and converge one runtime target.""" + """Assess desired state and converge one target after the external CD gate runs.""" def __init__( self, @@ -55,7 +54,7 @@ def assess( target: DeploymentTarget, adapter: DeploymentAdapter, ) -> DeploymentBundle: - """Build an immutable non-release candidate deployment bundle.""" + """Build an immutable candidate deployment bundle.""" proposal = self._governance.evaluate_proposal( base_contract, candidate_contract, @@ -70,7 +69,6 @@ def assess( plan = self._deployment.plan(source, target) preview = self._deployment.preview(plan, adapter=adapter) return build_deployment_bundle( - release=False, decision=proposal.decision, change_set=proposal.change_set, deployment_source=source, @@ -90,7 +88,6 @@ def assess_release( plan = self._deployment.plan(source, target) preview = self._deployment.preview(plan, adapter=adapter) return build_deployment_bundle( - release=True, contract_release=release, deployment_source=source, deployment_plan=plan, @@ -103,17 +100,23 @@ def deploy( *, adapter: DeploymentAdapter, operational_history: OperationalHistorySink | None = None, - metadata_projector: RuntimeReleaseMetadataProjector | None = None, ) -> DeploymentExecutionResult: - """Consume one exact target bundle, deploy from fresh runtime evidence, and verify.""" + """Apply one exact bundle after the caller's execution boundary allows CD. + + SemaPact validates governance/release provenance, runtime freshness and + deterministic operations. It does not turn ContractRelease into DEPLOY + authorization; protected CI/CD environments control whether this operation + may be invoked. + """ if not isinstance(bundle, DeploymentBundle): raise TypeError( f"bundle must be DeploymentBundle, got {type(bundle).__name__}" ) - if bundle.release: + release_record = None + if bundle.is_release: assert bundle.contract_release is not None - deployment_authorization = authorize_contract_release_deployment( + validate_contract_release_deployment_context( bundle.deployment_plan, bundle.deployment_source, bundle.contract_release, @@ -121,16 +124,11 @@ def deploy( release_record = bundle.contract_release else: assert bundle.decision is not None - deployment_authorization = authorize_candidate_deployment( + validate_candidate_deployment_context( bundle.deployment_plan, bundle.deployment_source, bundle.decision, ) - if not deployment_authorization.allowed: - raise ContractOpsAuthorizationError( - "Candidate deployment is blocked by governance" - ) - release_record = None started_at = datetime.now(timezone.utc) fresh_preview = None @@ -141,10 +139,9 @@ def deploy( bundle.deployment_plan, adapter=adapter, ) - self._deployment.execute( + self._deployment.apply( bundle.deployment_plan, fresh_preview, - deployment_authorization, adapter=adapter, ) reconciliation = self._deployment.verify( @@ -156,9 +153,9 @@ def deploy( if ( release_record is not None and status is RuntimeDriftStatus.IN_SYNC - and metadata_projector is not None + and isinstance(adapter, RuntimeReleaseMetadataProjector) ): - metadata_projector.project_release_metadata( + adapter.project_release_metadata( bundle.deployment_plan, RuntimeReleaseMetadata( contract_id=release_record.contract_id, @@ -172,7 +169,7 @@ def deploy( operational_history.record_deployment( build_operational_deployment_event( bundle_digest=bundle.bundle_digest, - release=bundle.release, + release=bundle.is_release, contract_release_id=( release_record.contract_release_id if release_record is not None @@ -187,9 +184,7 @@ def deploy( if fresh_preview is not None else None ), - deployment_authorization_id=( - deployment_authorization.deployment_authorization_id - ), + deployment_authorization_id=None, platform=bundle.deployment_plan.target.platform, runtime_target=bundle.deployment_plan.target.runtime_target, source_reference=bundle.deployment_plan.target.source_reference, @@ -208,7 +203,6 @@ def deploy( result = DeploymentExecutionResult( bundle_digest=bundle.bundle_digest, deployment_plan_id=bundle.deployment_plan.deployment_plan_id, - authorization_id=deployment_authorization.deployment_authorization_id, contract_release_id=( release_record.contract_release_id if release_record is not None @@ -223,16 +217,14 @@ def deploy( operational_history.record_deployment( build_operational_deployment_event( bundle_digest=bundle.bundle_digest, - release=bundle.release, + release=bundle.is_release, contract_release_id=result.contract_release_id, contract_id=bundle.deployment_plan.contract_id, contract_version=bundle.deployment_plan.contract_version, revision_ref=bundle.deployment_plan.revision_ref, deployment_plan_id=bundle.deployment_plan.deployment_plan_id, deployment_preview_id=fresh_preview.deployment_preview_id, - deployment_authorization_id=( - deployment_authorization.deployment_authorization_id - ), + deployment_authorization_id=None, platform=bundle.deployment_plan.target.platform, runtime_target=bundle.deployment_plan.target.runtime_target, source_reference=bundle.deployment_plan.target.source_reference, From b803cb7fc24a6721fb552d4bab6ed68f1e1b2980 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:18:55 +1000 Subject: [PATCH 243/616] feat(cli): hand off finalized releases as artifacts --- semapact/interfaces/cli.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index 6394f6aa..637c4166 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -326,6 +326,10 @@ def _build_parser() -> argparse.ArgumentParser: required=True, help="Write the exact released/versioned ODCS contract to this path", ) + release_finalize_parser.add_argument( + "--release-out", + help="Write the finalized immutable ContractRelease JSON artifact to this path", + ) release_finalize_parser.add_argument( "--repository-root", default=".", @@ -517,9 +521,14 @@ def _build_parser() -> argparse.ArgumentParser: deployment_assess_parser.add_argument("--candidate") deployment_assess_parser.add_argument("--base-revision-ref") deployment_assess_parser.add_argument("--candidate-revision-ref") - deployment_assess_parser.add_argument( + release_source_group = deployment_assess_parser.add_mutually_exclusive_group() + release_source_group.add_argument( + "--release", + help="Finalized ContractRelease JSON artifact to deploy instead of a candidate", + ) + release_source_group.add_argument( "--release-id", - help="Finalized ContractReleaseRecord ID to deploy instead of a candidate", + help="Convenience fallback: resolve a finalized ContractRelease ID from Git history", ) deployment_assess_parser.add_argument( "--repository-root", From af513d88d100377c1979dff42cfbe6605d219ca3 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:19:04 +1000 Subject: [PATCH 244/616] feat(release): emit finalized ContractRelease artifact --- semapact/interfaces/commands/release_cmd.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index fdaeaac5..fbcd81e5 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -77,6 +77,8 @@ def run_release_finalize(args: argparse.Namespace) -> dict[str, Any]: approval=approval, ) output_path = dump_yaml(bundle.release_snapshot.to_contract(), args.output_contract) + if args.release_out: + _write_model_artifact(args.release_out, record) return { "contractReleaseId": record.contract_release_id, "contractId": record.contract_id, @@ -84,6 +86,7 @@ def run_release_finalize(args: argparse.Namespace) -> dict[str, Any]: "sourceRevisionRef": record.source_revision_ref, "releaseBundleDigest": bundle.bundle_digest, "outputContract": str(output_path), + "releaseArtifact": args.release_out, } From be9f4d4ff845a1bbfde461dd212516318d6d5bd7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:19:14 +1000 Subject: [PATCH 245/616] refactor(cli): prefer ContractRelease artifact over Git lookup --- .../interfaces/commands/deployment_cmd.py | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index a2cfa875..0c9552a9 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -41,6 +41,7 @@ def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: DeploymentWorkflowService, ) from semapact.core.loader import ContractLoader + from semapact.contractops import ContractRelease from semapact.governance import GovernanceOperation, evaluate_governance_gate from semapact.platforms.git import GitWorkingTreeHistoryRepository from semapact.platforms.runtime_registry import ( @@ -51,11 +52,15 @@ def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: service = DeploymentWorkflowService() loader = ContractLoader(runtime_context=args.runtime_context) - if args.release_id: + if args.release or args.release_id: _reject_candidate_args_for_release(args) - release = GitWorkingTreeHistoryRepository( - args.repository_root - ).get_contract_release(args.release_id) + release = ( + _load_model(args.release, ContractRelease) + if args.release + else GitWorkingTreeHistoryRepository( + args.repository_root + ).get_contract_release(args.release_id) + ) source_contract = OpenDataContractStandard.model_validate_json( release.released_contract_json ) @@ -158,23 +163,15 @@ def run_deployment_deploy(args: argparse.Namespace) -> DeploymentCommandResult: execution_config=execution_config, ) - from semapact.deployment import RuntimeReleaseMetadataProjector from semapact.history import create_operational_history_sink operational_history = create_operational_history_sink( _resolve_operational_history_uri(args.operational_history) ) - metadata_projector = ( - adapter - if isinstance(adapter, RuntimeReleaseMetadataProjector) - else None - ) - result = DeploymentWorkflowService().deploy( bundle, adapter=adapter, operational_history=operational_history, - metadata_projector=metadata_projector, ) rendered = ( _model_json(result) @@ -216,7 +213,7 @@ def _reject_candidate_args_for_release(args: argparse.Namespace) -> None: ] if supplied: raise ValidationError( - "--release-id cannot be combined with candidate assessment arguments: " + "Release deployment cannot be combined with candidate assessment arguments: " + ", ".join(supplied) ) From 5c084be5a5ae186275efbec3de4c1728d969de3b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:19:40 +1000 Subject: [PATCH 246/616] docs(examples): hand off data product release artifact directly --- examples/github/data-product-ci-cd.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/examples/github/data-product-ci-cd.yml b/examples/github/data-product-ci-cd.yml index b1a24476..fc26e879 100644 --- a/examples/github/data-product-ci-cd.yml +++ b/examples/github/data-product-ci-cd.yml @@ -128,8 +128,6 @@ jobs: # Example policy: GitHub Environment protects formal release publication. # SemaPact only persists an ApprovalRecord when its decision is REVIEW. environment: contract-release - outputs: - release_id: ${{ steps.finalize.outputs.release_id }} steps: - name: Checkout main uses: actions/checkout@v4 @@ -166,9 +164,8 @@ jobs: semapact release finalize \ --bundle artifacts/release.bundle.json \ --output-contract "${CONTRACT_PATH}" \ + --release-out artifacts/contract-release.json \ > artifacts/finalize.json - RELEASE_ID=$(jq -r '.contractReleaseId' artifacts/finalize.json) - echo "release_id=${RELEASE_ID}" >> "${GITHUB_OUTPUT}" - name: Commit released contract and governance ledger once run: | @@ -185,7 +182,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: semapact-finalized-release - path: artifacts/finalize.json + path: | + artifacts/finalize.json + artifacts/contract-release.json deploy-production: needs: finalize-release @@ -209,11 +208,16 @@ jobs: - name: Install SemaPact run: pip install "semapact[databricks]" + - name: Download finalized release artifact + uses: actions/download-artifact@v4 + with: + name: semapact-finalized-release + path: artifacts + - name: Assess finalized release against production run: | - mkdir -p artifacts semapact deployment assess \ - --release-id "${{ needs.finalize-release.outputs.release_id }}" \ + --release artifacts/contract-release.json \ --server "${PROD_SERVER}" \ --bundle-out artifacts/production.deployment.bundle.json From 1f02961a1906a97aebfd9df9e3a77c678ad0091d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:19:43 +1000 Subject: [PATCH 247/616] docs(examples): hand off central repo release artifacts directly --- .../github/central-contract-repo-ci-cd.yml | 38 +++++-------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/examples/github/central-contract-repo-ci-cd.yml b/examples/github/central-contract-repo-ci-cd.yml index b5f765ab..798f71dd 100644 --- a/examples/github/central-contract-repo-ci-cd.yml +++ b/examples/github/central-contract-repo-ci-cd.yml @@ -209,8 +209,6 @@ jobs: - release-assess runs-on: ubuntu-latest environment: contract-release - outputs: - manifest_artifact: finalized-release-manifest steps: - name: Checkout main uses: actions/checkout@v4 @@ -236,7 +234,7 @@ jobs: env: MATRIX: ${{ needs.detect-contracts.outputs.matrix }} run: | - echo '[]' > artifacts/finalized-release-manifest.json + mkdir -p artifacts/finalized echo "${MATRIX}" | jq -c '.include[]' | while read -r ITEM; do CONTRACT_PATH=$(echo "${ITEM}" | jq -r '.contract_path') ARTIFACT=$(echo "${ITEM}" | jq -r '.artifact') @@ -252,15 +250,8 @@ jobs: semapact release finalize \ --bundle "${BUNDLE}" \ --output-contract "${CONTRACT_ROOT}/${CONTRACT_PATH}" \ - > /tmp/finalize.json - - RELEASE_ID=$(jq -r '.contractReleaseId' /tmp/finalize.json) - jq --arg artifact "${ARTIFACT}" \ - --arg contract_path "${CONTRACT_PATH}" \ - --arg release_id "${RELEASE_ID}" \ - '. + [{artifact: $artifact, contract_path: $contract_path, release_id: $release_id}]' \ - artifacts/finalized-release-manifest.json > /tmp/manifest.json - mv /tmp/manifest.json artifacts/finalized-release-manifest.json + --release-out "artifacts/finalized/${ARTIFACT}.contract-release.json" \ + > "/tmp/${ARTIFACT}.finalize.json" done - name: Commit all released contracts and governance facts once @@ -274,11 +265,11 @@ jobs: git push origin HEAD:main fi - - name: Upload finalized release manifest + - name: Upload finalized ContractRelease artifacts uses: actions/upload-artifact@v4 with: - name: finalized-release-manifest - path: artifacts/finalized-release-manifest.json + name: semapact-finalized-releases + path: artifacts/finalized/ deploy-production: needs: @@ -307,20 +298,11 @@ jobs: - name: Install SemaPact run: pip install "semapact[databricks]" - - name: Download finalized release manifest + - name: Download finalized ContractRelease artifacts uses: actions/download-artifact@v4 with: - name: finalized-release-manifest - path: artifacts - - - name: Resolve finalized release identity - id: release - env: - ARTIFACT: ${{ matrix.artifact }} - run: | - RELEASE_ID=$(jq -r --arg artifact "${ARTIFACT}" '.[] | select(.artifact == $artifact) | .release_id' artifacts/finalized-release-manifest.json) - test -n "${RELEASE_ID}" && test "${RELEASE_ID}" != "null" - echo "release_id=${RELEASE_ID}" >> "${GITHUB_OUTPUT}" + name: semapact-finalized-releases + path: artifacts/releases - name: Assess finalized release against production env: @@ -328,7 +310,7 @@ jobs: run: | mkdir -p artifacts/deploy semapact deployment assess \ - --release-id "${{ steps.release.outputs.release_id }}" \ + --release "artifacts/releases/${ARTIFACT}.contract-release.json" \ --server "${PROD_SERVER}" \ --bundle-out "artifacts/deploy/${ARTIFACT}.production.deployment.bundle.json" From e8eb7244e7b6eba1b40f8ef90a76e39dcdff0182 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:20:01 +1000 Subject: [PATCH 248/616] test(examples): require direct ContractRelease artifact handoff --- tests/test_semapact_devops_examples_readable.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_semapact_devops_examples_readable.py b/tests/test_semapact_devops_examples_readable.py index 430ec342..27f04326 100644 --- a/tests/test_semapact_devops_examples_readable.py +++ b/tests/test_semapact_devops_examples_readable.py @@ -53,7 +53,8 @@ def test_data_product_github_example_uses_bundle_driven_ci_cd() -> None: assert "release approve" in workflow assert "release finalize" in workflow assert "deployment assess" in workflow - assert "--release-id" in workflow + assert "--release " in workflow + assert "--release-id" not in workflow assert "deployment deploy" in workflow assert "environment: contract-release" in workflow assert "environment: production" in workflow @@ -73,9 +74,10 @@ def test_central_contract_repo_github_example_fans_out_and_commits_ledger_once() assert "release finalize" in workflow assert "finalize-releases:" in workflow assert "deployment assess" in workflow - assert "--release-id" in workflow + assert "--release " in workflow + assert "--release-id" not in workflow assert "deployment deploy" in workflow - assert "merge-multiple: true" in workflow + assert "semapact-finalized-releases" in workflow assert "--operational-history" not in workflow From 0bedde4152fcdf06e1d16f8fff67e377c72d82da Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:20:04 +1000 Subject: [PATCH 249/616] test(release): cover finalized release artifact output --- tests/interfaces/test_release_workflow_cmd.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/interfaces/test_release_workflow_cmd.py b/tests/interfaces/test_release_workflow_cmd.py index 4694abb4..76e628cb 100644 --- a/tests/interfaces/test_release_workflow_cmd.py +++ b/tests/interfaces/test_release_workflow_cmd.py @@ -51,6 +51,7 @@ def test_release_finalize_materializes_selected_version_and_history(tmp_path) -> candidate = tmp_path / "contract.yaml" bundle = tmp_path / "release.bundle.json" released = tmp_path / "released.yaml" + release_artifact = tmp_path / "contract-release.json" dump_yaml(_contract(), base) dump_yaml(_contract(include_created_at=True), candidate) @@ -80,6 +81,7 @@ def test_release_finalize_materializes_selected_version_and_history(tmp_path) -> SimpleNamespace( bundle=str(bundle), output_contract=str(released), + release_out=str(release_artifact), repository_root=str(tmp_path), ) ) @@ -93,3 +95,5 @@ def test_release_finalize_materializes_selected_version_and_history(tmp_path) -> assert record.source_revision_ref == "git:candidate" assert result["sourceRevisionRef"] == "git:candidate" assert record.released_contract_json + assert release_artifact.is_file() + assert release_artifact.read_text(encoding="utf-8").strip() From a5fec3844ae119a1d8a92cc1e3c331f372bac3fe Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:20:07 +1000 Subject: [PATCH 250/616] test(cli): prefer finalized release artifact input --- tests/interfaces/test_deployment_cmd.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index 3eccf7fe..ea1ac6a4 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -44,8 +44,8 @@ def test_deployment_parser_exposes_candidate_and_finalized_release_modes() -> No [ "deployment", "assess", - "--release-id", - "release-1", + "--release", + "contract-release.json", "--server", "production", "--bundle-out", @@ -73,7 +73,8 @@ def test_deployment_parser_exposes_candidate_and_finalized_release_modes() -> No assert candidate.bundle_out == "candidate.bundle.json" assert release.deployment_command == "assess" - assert release.release_id == "release-1" + assert release.release == "contract-release.json" + assert release.release_id is None assert release.repository_root == "." assert release.server == "production" From bec08fc8988a6b3c1d8cc1beefefdf989f472faa Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:20:23 +1000 Subject: [PATCH 251/616] test(deployment): cover canonical apply and derived deployment mode --- tests/test_deployment_workflow_service.py | 44 +++++++++++------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index d8c9929c..c5ff1d82 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -73,6 +73,9 @@ def preview(self, plan) -> DeploymentPreview: operations=operations, ) + def apply(self, plan, preview) -> None: + raise AssertionError("CI bundle construction must not apply") + def execute(self, plan, preview, authorization) -> None: raise AssertionError("CI bundle construction must not execute") @@ -88,6 +91,7 @@ def __init__(self) -> None: self.execute_calls = 0 self.verify_calls = 0 self.executed_preview = None + self.metadata_calls = [] def validate(self, plan) -> None: pass @@ -126,11 +130,17 @@ def preview(self, plan) -> DeploymentPreview: operations=operations, ) + def apply(self, plan, preview) -> None: + self.execute_calls += 1 + self.executed_preview = preview + def execute(self, plan, preview, authorization) -> None: assert authorization.allowed is True assert authorization.source_snapshot_id == plan.source_snapshot_id - self.execute_calls += 1 - self.executed_preview = preview + self.apply(plan, preview) + + def project_release_metadata(self, plan, metadata) -> None: + self.metadata_calls.append((plan, metadata)) def verify(self, plan) -> ReconciliationResult: self.verify_calls += 1 @@ -151,18 +161,10 @@ def record_deployment(self, event) -> None: class _FailingExecutionAdapter(_ExecutionAdapter): - def execute(self, plan, preview, authorization) -> None: + def apply(self, plan, preview) -> None: raise RuntimeError("provider mutation failed") -class _MetadataProjector: - def __init__(self) -> None: - self.calls = [] - - def project_release_metadata(self, plan, metadata) -> None: - self.calls.append((plan, metadata)) - - def _contract( *, name: str, @@ -238,14 +240,14 @@ def test_candidate_assessment_does_not_calculate_release_version() -> None: adapter=_PreviewAdapter(), ) - assert bundle.release is False + assert bundle.is_release is False assert bundle.contract_release is None assert bundle.decision is not None assert bundle.change_set is not None - assert bundle.deployment_source.release is False + assert bundle.deployment_source.source_kind == "candidate" assert bundle.deployment_source.contract_version == "1.2.3" - assert bundle.deployment_plan.release is False - assert bundle.deployment_plan.plan_version == "4" + assert bundle.deployment_plan.is_release is False + assert bundle.deployment_plan.plan_version == "5" def test_finalized_release_assessment_binds_exact_release_record(tmp_path) -> None: @@ -256,7 +258,7 @@ def test_finalized_release_assessment_binds_exact_release_record(tmp_path) -> No adapter=_PreviewAdapter(), ) - assert bundle.release is True + assert bundle.is_release is True assert bundle.decision is None assert bundle.change_set is None assert bundle.contract_release == release @@ -405,18 +407,16 @@ def test_release_deployment_projects_finalized_version_after_in_sync(tmp_path) - target=_target(), adapter=_PreviewAdapter(), ) - projector = _MetadataProjector() - + adapter = _ExecutionAdapter() result = DeploymentWorkflowService().deploy( bundle, - adapter=_ExecutionAdapter(), - metadata_projector=projector, + adapter=adapter, ) assert result.status is RuntimeDriftStatus.IN_SYNC assert result.contract_release_id == release.contract_release_id - assert len(projector.calls) == 1 - plan, metadata = projector.calls[0] + assert len(adapter.metadata_calls) == 1 + plan, metadata = adapter.metadata_calls[0] assert metadata.contract_version == release.contract_version assert metadata.contract_release_id == release.contract_release_id assert plan.release_id == release.contract_release_id From f7f990ffdf2a4d057a13d0ff3c8e762c338b9cb0 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:20:34 +1000 Subject: [PATCH 252/616] test(deployment): cover canonical authorization-free apply --- tests/test_deployment_service.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/test_deployment_service.py b/tests/test_deployment_service.py index 257106d4..57f088e7 100644 --- a/tests/test_deployment_service.py +++ b/tests/test_deployment_service.py @@ -59,6 +59,10 @@ def verify(self, plan: DeploymentPlan) -> ReconciliationResult: self.verify_calls += 1 return self.verification_result + def apply(self, plan, preview) -> None: + self.execute_calls += 1 + self.executed = (plan, preview, None) + def execute(self, plan, preview, authorization) -> None: self.execute_calls += 1 self.executed = (plan, preview, authorization) @@ -195,6 +199,21 @@ def test_preview_delegates_to_unified_adapter_entrypoint() -> None: assert adapter.execute_calls == 0 +def test_apply_delegates_exact_plan_and_preview_without_internal_authorization() -> None: + plan = _plan() + preview = _preview(plan) + adapter = _adapter(plan) + + DeploymentService().apply( + plan, + preview, + adapter=adapter, + ) + + assert adapter.execute_calls == 1 + assert adapter.executed == (plan, preview, None) + + def test_execute_delegates_exact_canonical_artifacts() -> None: plan = _plan() preview = _preview(plan) @@ -222,7 +241,7 @@ def test_verify_delegates_to_same_adapter_entrypoint() -> None: assert adapter.verify_calls == 1 -@pytest.mark.parametrize("operation", ["preview", "verify", "execute"]) +@pytest.mark.parametrize("operation", ["preview", "verify", "apply", "execute"]) def test_service_rejects_adapter_platform_mismatch(operation: str) -> None: plan = _plan() adapter = _adapter(plan) @@ -233,6 +252,8 @@ def test_service_rejects_adapter_platform_mismatch(operation: str) -> None: DeploymentService().preview(plan, adapter=adapter) elif operation == "verify": DeploymentService().verify(plan, adapter=adapter) + elif operation == "apply": + DeploymentService().apply(plan, _preview(plan), adapter=adapter) else: DeploymentService().execute( plan, From dedb95c9238c51f2525757b5e734315c0467819b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:21:46 +1000 Subject: [PATCH 253/616] refactor(release): make ContractRelease construction pure --- semapact/application/models/release.py | 38 +++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/semapact/application/models/release.py b/semapact/application/models/release.py index d028fe6a..480cc12b 100644 --- a/semapact/application/models/release.py +++ b/semapact/application/models/release.py @@ -8,7 +8,14 @@ from pydantic import BaseModel, ConfigDict, model_validator -from semapact.contractops import ChangeSet, ReleasePlan, ReleaseSnapshot, VersionResolution +from semapact.contractops import ( + ChangeSet, + ContractRelease, + ReleasePlan, + ReleaseSnapshot, + VersionResolution, +) +from semapact.contractops.integrity import compute_contract_release_id from semapact.governance import GovernanceDecision from semapact.utils.deterministic import canonical_compact_json @@ -121,3 +128,32 @@ def compute_release_bundle_digest( } encoded = canonical_compact_json(payload).encode("utf-8") return f"sha256:{hashlib.sha256(encoded).hexdigest()}" + + +def build_contract_release(bundle: ReleaseBundle) -> ContractRelease: + """Materialize the canonical target-neutral release fact from one exact bundle.""" + snapshot = bundle.release_snapshot + resolution = bundle.version_resolution + release_id = compute_contract_release_id( + contract_id=snapshot.contract_id, + contract_version=snapshot.selected_version, + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=resolution.version_resolution_id, + release_snapshot_id=snapshot.release_snapshot_id, + source_revision_ref=snapshot.release_revision_ref, + released_contract_json=snapshot.released_contract_json, + ) + return ContractRelease( + contract_release_id=release_id, + contract_id=snapshot.contract_id, + contract_version=snapshot.selected_version, + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=resolution.version_resolution_id, + release_snapshot_id=snapshot.release_snapshot_id, + source_revision_ref=snapshot.release_revision_ref, + released_contract_json=snapshot.released_contract_json, + ) From 94fc78b83aa3d0e462c9afc1ef04b5b187306437 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:21:55 +1000 Subject: [PATCH 254/616] refactor(release): keep persistence out of release construction --- .../application/services/release_workflow.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/semapact/application/services/release_workflow.py b/semapact/application/services/release_workflow.py index 07a56ba2..d7abcc47 100644 --- a/semapact/application/services/release_workflow.py +++ b/semapact/application/services/release_workflow.py @@ -8,9 +8,10 @@ from semapact.approval import ApprovalRecord, build_approval_record from semapact.approval.evidence import project_review_authorization_evidence -from semapact.application.models.release import ReleaseBundle, build_release_bundle -from semapact.application.services.contract_release_history import ( - ContractReleaseHistoryService, +from semapact.application.models.release import ( + ReleaseBundle, + build_contract_release, + build_release_bundle, ) from semapact.application.services.release_planning import ReleasePlanningService from semapact.contractops import ( @@ -20,7 +21,8 @@ ) from semapact.exceptions import ContractOpsAuthorizationError, ValidationError from semapact.governance import DecisionResult, GovernanceOperation -from semapact.history import ContractReleaseRecord +from semapact.contractops import ContractRelease +from semapact.history import ContractReleaseHistoryRepository class ReleaseWorkflowService: @@ -99,9 +101,9 @@ def finalize( self, bundle: ReleaseBundle, *, - release_history: ContractReleaseHistoryService, + release_history: ContractReleaseHistoryRepository, approval: ApprovalRecord | None = None, - ) -> ContractReleaseRecord: + ) -> ContractRelease: """Authorize and persist one formal release exactly once.""" evidence = None if bundle.decision.decision is DecisionResult.REVIEW: @@ -134,4 +136,6 @@ def finalize( "Formal release is not authorized: " f"{authorization.reason.value}" ) - return release_history.record_release(bundle, approval=approval) + release = build_contract_release(bundle) + release_history.put_contract_release(release) + return release From a16632cce6eaae362b1e6bde11c2731177308f52 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:22:04 +1000 Subject: [PATCH 255/616] refactor(release): persist finalized release through repository port --- semapact/interfaces/commands/release_cmd.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index fbcd81e5..3ec71736 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -56,9 +56,6 @@ def run_release_approve(args: argparse.Namespace) -> dict[str, Any]: def run_release_finalize(args: argparse.Namespace) -> dict[str, Any]: """Finalize one exact release, persist ledger fact, and materialize versioned ODCS.""" from semapact.application.models.release import ReleaseBundle - from semapact.application.services.contract_release_history import ( - ContractReleaseHistoryService, - ) from semapact.application.services.release_approval import ReleaseApprovalResolver from semapact.application.services.release_workflow import ReleaseWorkflowService from semapact.governance import DecisionResult @@ -73,7 +70,7 @@ def run_release_finalize(args: argparse.Namespace) -> dict[str, Any]: record = ReleaseWorkflowService().finalize( bundle, - release_history=ContractReleaseHistoryService(repository), + release_history=repository, approval=approval, ) output_path = dump_yaml(bundle.release_snapshot.to_contract(), args.output_contract) From a352d027e916eb89e40042fcd61b7aee12aa2382 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:22:07 +1000 Subject: [PATCH 256/616] test(release): use repository as persistence boundary --- tests/test_release_workflow_service.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/test_release_workflow_service.py b/tests/test_release_workflow_service.py index 57819684..e41e5aff 100644 --- a/tests/test_release_workflow_service.py +++ b/tests/test_release_workflow_service.py @@ -11,9 +11,6 @@ from pydantic import ValidationError as PydanticValidationError from semapact.application.models.release import ReleaseBundle -from semapact.application.services.contract_release_history import ( - ContractReleaseHistoryService, -) from semapact.application.services.release_approval import ReleaseApprovalResolver from semapact.application.services.release_workflow import ReleaseWorkflowService from semapact.exceptions import ContractOpsAuthorizationError @@ -103,7 +100,7 @@ def test_review_release_finalize_requires_exact_approval(tmp_path) -> None: with pytest.raises(ContractOpsAuthorizationError, match="requires approval"): workflow.finalize( bundle, - release_history=ContractReleaseHistoryService(repository), + release_history=repository, ) approval = workflow.approve( @@ -114,7 +111,7 @@ def test_review_release_finalize_requires_exact_approval(tmp_path) -> None: repository.put_approval_record(approval) record = workflow.finalize( bundle, - release_history=ContractReleaseHistoryService(repository), + release_history=repository, approval=approval, ) From 58df13de8670c2f30c4163f981403f4049f20527 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:22:10 +1000 Subject: [PATCH 257/616] test(deployment): finalize release through repository port --- tests/test_deployment_workflow_service.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index c5ff1d82..65ae8d24 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -10,9 +10,6 @@ ) from pydantic import ValidationError as PydanticValidationError -from semapact.application.services.contract_release_history import ( - ContractReleaseHistoryService, -) from semapact.application.services.deployment_workflow import DeploymentWorkflowService from semapact.application.services.release_workflow import ReleaseWorkflowService from semapact.deployment import ( @@ -224,7 +221,7 @@ def _finalized_release(tmp_path): repository.put_approval_record(approval) return workflow.finalize( bundle, - release_history=ContractReleaseHistoryService(repository), + release_history=repository, approval=approval, ) From b52754400b62c34ef7d8192a4dba32aede2ccd96 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:22:19 +1000 Subject: [PATCH 258/616] refactor(release): remove mixed-responsibility release history service --- .../services/contract_release_history.py | 68 ------------------- 1 file changed, 68 deletions(-) delete mode 100644 semapact/application/services/contract_release_history.py diff --git a/semapact/application/services/contract_release_history.py b/semapact/application/services/contract_release_history.py deleted file mode 100644 index 4ee64a43..00000000 --- a/semapact/application/services/contract_release_history.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Application service for formal contract release ledger persistence.""" - -from __future__ import annotations - -from semapact.approval import ApprovalRecord -from semapact.application.models.release import ReleaseBundle -from semapact.contractops import ContractRelease -from semapact.contractops.integrity import compute_contract_release_id -from semapact.governance import DecisionResult, GovernanceOperation -from semapact.history import ContractReleaseHistoryRepository - - -class ContractReleaseHistoryService: - """Persist one target-neutral formal release fact.""" - - def __init__(self, releases: ContractReleaseHistoryRepository) -> None: - self._releases = releases - - def record_release( - self, - bundle: ReleaseBundle, - *, - approval: ApprovalRecord | None, - ) -> ContractRelease: - """Persist one finalized release, idempotently, after exact review validation.""" - if bundle.decision.decision is DecisionResult.REVIEW: - if approval is None: - raise ValueError("REVIEW release history requires exact approval") - if approval.operation is not GovernanceOperation.PUBLISH: - raise ValueError("Release approval must be PUBLISH-scoped") - if approval.scope_reference != bundle.release_snapshot.release_snapshot_id: - raise ValueError( - "Release approval is not scoped to exact ReleaseSnapshot" - ) - if bundle.bundle_digest not in approval.evidence_references: - raise ValueError( - "Release approval does not reference exact ReleaseBundle digest" - ) - elif approval is not None: - raise ValueError("ALLOW release does not require ApprovalRecord") - - snapshot = bundle.release_snapshot - resolution = bundle.version_resolution - record_id = compute_contract_release_id( - contract_id=snapshot.contract_id, - contract_version=snapshot.selected_version, - decision_id=bundle.decision.decision_id, - change_set_id=bundle.change_set.change_set_id, - release_plan_id=bundle.release_plan.release_plan_id, - version_resolution_id=resolution.version_resolution_id, - release_snapshot_id=snapshot.release_snapshot_id, - source_revision_ref=snapshot.release_revision_ref, - released_contract_json=snapshot.released_contract_json, - ) - record = ContractRelease( - contract_release_id=record_id, - contract_id=snapshot.contract_id, - contract_version=snapshot.selected_version, - decision_id=bundle.decision.decision_id, - change_set_id=bundle.change_set.change_set_id, - release_plan_id=bundle.release_plan.release_plan_id, - version_resolution_id=resolution.version_resolution_id, - release_snapshot_id=snapshot.release_snapshot_id, - source_revision_ref=snapshot.release_revision_ref, - released_contract_json=snapshot.released_contract_json, - ) - self._releases.put_contract_release(record) - return record From d170e1c01d0d4e8cdb1e366d4e36b2068e656d4e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:23:05 +1000 Subject: [PATCH 259/616] docs(readme): separate release provenance from deployment authority --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d2bbca7f..b2882395 100644 --- a/README.md +++ b/README.md @@ -133,9 +133,9 @@ DRAFT → ACTIVE → DEPRECATED → RETIRED Lifecycle status does not itself mean that a revision has been authorized for release. -### Side effects are operation-scoped +### Side effects have explicit trust boundaries -SemaPact distinguishes pure planning/materialization from external side effects. PUBLISH and DEPLOY are protected operation scopes; release snapshot construction itself is pure. A publication authorization cannot be reused as runtime deployment authority. +SemaPact distinguishes pure planning/materialization from external side effects. Formal release PUBLISH approval is recorded explicitly when governance requires review. Runtime DEPLOY permission belongs to the surrounding protected execution boundary (for example a GitHub or Azure DevOps Environment), not to the ContractRelease artifact. Release state therefore cannot authorize runtime mutation by itself. ### Runtime-aware without becoming platform-owned @@ -218,7 +218,7 @@ orders@1.4.0 Deployment is target-specific: ```text -deployment assess --release-id +deployment assess --release ./artifacts/contract-release.json → DeploymentSourceSnapshot → DeploymentPlan → fresh runtime preview From 00996502f79c8227dce08b44b814e6b5846ec6d3 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:23:28 +1000 Subject: [PATCH 260/616] docs(deployment): document provenance-first canonical workflow --- docs/deployment_plans.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 200d8e11..8f61ece9 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -5,7 +5,7 @@ SemaPact treats a released ODCS contract as governed desired state, not as an ex The released contract is the authoritative artifact. SemaPact does **not** require a separate DDL build artifact before deployment. SQL and other provider-native commands are derived only after an exact released desired state is compared with an exact runtime target. ```text -ReleaseSnapshot +Candidate contract or finalized ContractRelease = exact governed desired state + ObservedPlatformState @@ -23,13 +23,11 @@ This matters because one release may require different native operations in diff The deployment planning boundary is therefore: ```text -ReleaseSnapshot +DeploymentSourceSnapshot + DeploymentTarget ↓ DeploymentPlan ↓ -DeploymentAuthorization - ↓ DeploymentService ↓ DeploymentAdapter interface @@ -42,8 +40,8 @@ DeploymentOrchestrator → transition → compile → preview - → freshness / exact authorization - → execute + → freshness validation + → apply → verify ↓ provider NativeOperationExecutor / RuntimeProvider @@ -55,9 +53,9 @@ The orchestration above is provider-neutral. Platform packages configure or impl ## What a DeploymentPlan means -A `DeploymentPlan` is a deterministic, provider-neutral statement of the runtime state that one exact release snapshot intends to converge toward. +A `DeploymentPlan` is a deterministic, provider-neutral statement of the runtime state that one exact deployment source intends to converge toward. -The canonical CI path builds it from a pure `ReleaseSnapshot`, which freezes the selected contract revision/version without requiring side-effect authorization. Legacy `AppliedContractRelease` inputs remain readable for v2-plan compatibility, but new CI bundles use v3 plans bound to `releaseId`. +Canonical plans are v5 and bind only the immutable source snapshot plus optional release provenance. Candidate versus release mode is not stored as another boolean: it is derived from the source provenance. Legacy `AppliedContractRelease` / `ReleaseSnapshot` plan payloads remain readable behind compatibility seams. The initial action vocabulary deliberately contains only: @@ -199,7 +197,7 @@ Candidate deployment: ```text base + candidate → GovernanceDecision + ChangeSet -→ DeploymentSourceSnapshot(release=false) +→ DeploymentSourceSnapshot(source_kind=candidate) → target-specific DeploymentPlan → fresh DeploymentPreview → DeploymentBundle @@ -209,7 +207,7 @@ Finalized-release deployment: ```text ContractRelease -→ DeploymentSourceSnapshot(release=true) +→ DeploymentSourceSnapshot(source_kind=contract_release) → target-specific DeploymentPlan → fresh DeploymentPreview → DeploymentBundle @@ -270,7 +268,8 @@ Finalize exactly once: ```bash semapact release finalize \ --bundle ./artifacts/orders.release.bundle.json \ - --output-contract ./contracts/orders.yaml + --output-contract ./contracts/orders.yaml \ + --release-out ./artifacts/orders.contract-release.json ``` Finalization: @@ -279,16 +278,16 @@ Finalization: - writes one immutable `ContractRelease` to the Git governance ledger; - writes the selected version back to the ODCS contract file. -After finalization, build any target-specific deployment bundle from the release identity: +After finalization, hand the immutable ContractRelease artifact directly to target-specific deployment: ```bash semapact deployment assess \ - --release-id \ + --release ./artifacts/orders.contract-release.json \ --server production \ --bundle-out ./artifacts/orders-prod.deployment.bundle.json ``` -The same `contract-release-id` may be assessed/deployed against dev, test, prod, or another runtime target. +The same ContractRelease artifact may be assessed/deployed against dev, test, prod, or another runtime target. `--release-id` remains a convenience fallback for resolving the same artifact from the Git governance ledger; Git history is not the canonical CI/CD transport. ### CI/CD handoff rules @@ -311,11 +310,12 @@ CD must not rebuild either artifact from mutable repository state after its trus At execution time: ```text -exact DeploymentBundle -→ authorize exact candidate/finalized-release source +protected CI/CD execution boundary +→ exact DeploymentBundle +→ validate candidate/finalized-release provenance → fresh runtime observation → fresh DeploymentPreview -→ execute +→ apply → fresh reconciliation → IN_SYNC / DRIFT / INDETERMINATE ``` From 444ff17d394a33f5df75c90b6f276449787d992d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:23:59 +1000 Subject: [PATCH 261/616] docs(readme): make release artifact the canonical handoff --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b2882395..c5cf059f 100644 --- a/README.md +++ b/README.md @@ -405,16 +405,17 @@ semapact release approve \ semapact release finalize \ --bundle ./artifacts/orders.release.bundle.json \ - --output-contract ./contracts/orders.yaml + --output-contract ./contracts/orders.yaml \ + --release-out ./artifacts/orders.contract-release.json ``` Finalization writes the selected semantic version back to the ODCS contract and records the immutable `ContractRelease` in the Git governance ledger. The release records the source revision from which it was derived; the finalized release identity is the immutable released-contract snapshot, not a claim that the source revision already contained the materialized version bump. -Deployment then consumes that finalized release identity: +Deployment then consumes the finalized release artifact directly: ```bash semapact deployment assess \ - --release-id \ + --release ./artifacts/orders.contract-release.json \ --server production \ --bundle-out ./artifacts/orders-prod.deployment.bundle.json From b323038f97f06ac70f95c130e3d25f01fadf5a86 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:24:11 +1000 Subject: [PATCH 262/616] fix(release): keep direct finalize callers compatible --- semapact/interfaces/commands/release_cmd.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index 3ec71736..0e67fd65 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -74,8 +74,9 @@ def run_release_finalize(args: argparse.Namespace) -> dict[str, Any]: approval=approval, ) output_path = dump_yaml(bundle.release_snapshot.to_contract(), args.output_contract) - if args.release_out: - _write_model_artifact(args.release_out, record) + release_out = getattr(args, "release_out", None) + if release_out: + _write_model_artifact(release_out, record) return { "contractReleaseId": record.contract_release_id, "contractId": record.contract_id, @@ -83,7 +84,7 @@ def run_release_finalize(args: argparse.Namespace) -> dict[str, Any]: "sourceRevisionRef": record.source_revision_ref, "releaseBundleDigest": bundle.bundle_digest, "outputContract": str(output_path), - "releaseArtifact": args.release_out, + "releaseArtifact": release_out, } From 4eea6578fac025c2a6e8fd52ae798f42eb4a56f7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:25:22 +1000 Subject: [PATCH 263/616] refactor(history): type release ledger against canonical ContractRelease --- semapact/history/repository.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/semapact/history/repository.py b/semapact/history/repository.py index d9150093..edece385 100644 --- a/semapact/history/repository.py +++ b/semapact/history/repository.py @@ -5,13 +5,12 @@ from typing import Protocol from semapact.approval.models import ApprovalRecord -from semapact.contractops import ChangeSet, ReleasePlan +from semapact.contractops import ChangeSet, ContractRelease, ReleasePlan from semapact.deployment import DeploymentAuthorization, DeploymentPlan, DeploymentPreview from semapact.governance import GovernanceDecision from semapact.governance.gate import GovernanceOperation from semapact.history.models import ( ChangeSetDecisionLink, - ContractReleaseRecord, DeploymentRecord, HistoryStorageIntegrityIssue, ReleaseRecord, @@ -131,17 +130,17 @@ def get_release_record_by_version( class ContractReleaseHistoryRepository(Protocol): """Persistence capability for finalized formal contract release facts.""" - def put_contract_release(self, record: ContractReleaseRecord) -> None: ... - def get_contract_release(self, contract_release_id: str) -> ContractReleaseRecord: ... + def put_contract_release(self, record: ContractRelease) -> None: ... + def get_contract_release(self, contract_release_id: str) -> ContractRelease: ... def list_contract_releases( self, contract_id: str, - ) -> tuple[ContractReleaseRecord, ...]: ... + ) -> tuple[ContractRelease, ...]: ... def get_contract_release_by_version( self, contract_id: str, contract_version: str, - ) -> ContractReleaseRecord: ... + ) -> ContractRelease: ... class DeploymentPlanHistoryRepository(Protocol): From f71c2566047ec368f15b4f0f060efa0264e372e3 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:25:37 +1000 Subject: [PATCH 264/616] refactor(deployment): remove release-as-authorization compatibility API --- semapact/deployment/authorization.py | 30 ---------------------------- 1 file changed, 30 deletions(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index 69c1b4e3..3b4d7947 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -215,36 +215,6 @@ def validate_contract_release_deployment_context( ) -def authorize_contract_release_deployment( - plan: DeploymentPlan, - source: DeploymentSourceSnapshot, - release: ContractRelease, -) -> DeploymentAuthorization: - """Compatibility adapter for callers that still require an authorization artifact. - - Canonical CI/CD validates the release context and treats the protected execution - boundary as deployment authority; it does not interpret ContractRelease itself as - authorization. - """ - validate_contract_release_deployment_context(plan, source, release) - authorization_id = compute_deployment_authorization_id( - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=source.source_snapshot_id, - authorization_kind="contract_release", - authorization_reference=release.contract_release_id, - allowed=True, - authorization_version="2", - ) - return DeploymentAuthorization( - deployment_authorization_id=authorization_id, - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=source.source_snapshot_id, - allowed=True, - authorization_kind="contract_release", - authorization_reference=release.contract_release_id, - authorization_version="2", - ) - def _validate_plan_release_context( plan: DeploymentPlan, From 02b66d88876e038092ddaa146a2d33b89a141110 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:25:40 +1000 Subject: [PATCH 265/616] refactor(deployment): remove release authorization export --- semapact/deployment/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index e662fea3..402b5203 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -7,7 +7,6 @@ ) from semapact.deployment.authorization import ( authorize_candidate_deployment, - authorize_contract_release_deployment, authorize_deployment, validate_candidate_deployment_context, validate_contract_release_deployment_context, @@ -64,7 +63,6 @@ "TransitionCompiler", "SchemaTransitionPlanner", "authorize_candidate_deployment", - "authorize_contract_release_deployment", "authorize_deployment", "validate_candidate_deployment_context", "validate_contract_release_deployment_context", From 25f5c9bc43efd4657162078de5b110b8a78fe7f5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:25:44 +1000 Subject: [PATCH 266/616] refactor(deployment): remove contract release authorization kind --- semapact/deployment/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index 7b003da8..0847616a 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -334,7 +334,7 @@ class DeploymentAuthorization(DeploymentModel): deployment_plan_id: str source_snapshot_id: str allowed: bool = Field(strict=True) - authorization_kind: Literal["contractops", "governance", "contract_release"] = "contractops" + authorization_kind: Literal["contractops", "governance"] = "contractops" authorization_reference: str authorization_version: Literal["1", "2"] = "2" @@ -547,7 +547,7 @@ def compute_deployment_authorization_id( raise ValueError( f"Unsupported DeploymentAuthorization version: {authorization_version}" ) - if authorization_kind not in {"contractops", "governance", "contract_release"}: + if authorization_kind not in {"contractops", "governance"}: raise ValueError("Unsupported deployment authorization kind") source_id = _resolve_text_pair( source_snapshot_id, From abec92a4a15b7281931e9e7794c8a4c31562a919 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:25:56 +1000 Subject: [PATCH 267/616] refactor(databricks): derive release mode from plan provenance --- semapact/platforms/databricks/deployment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/platforms/databricks/deployment.py b/semapact/platforms/databricks/deployment.py index 876f5a45..afe48ff2 100644 --- a/semapact/platforms/databricks/deployment.py +++ b/semapact/platforms/databricks/deployment.py @@ -158,7 +158,7 @@ def project_release_metadata( metadata: RuntimeReleaseMetadata, ) -> None: """Project SemaPact release provenance into Unity Catalog table tags.""" - if not plan.release: + if not plan.is_release: raise ValidationError( "Release metadata projection requires a formal release DeploymentPlan" ) From 15f457d82036fd23c47645191b7f15ee743c0b05 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:27:10 +1000 Subject: [PATCH 268/616] docs(examples): distinguish publish approval from deploy execution gate --- examples/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 70290e5d..8bfc48ce 100644 --- a/examples/README.md +++ b/examples/README.md @@ -48,4 +48,6 @@ Both examples assume contracts define Databricks servers named `development` and Operational deployment history is config-first through `.semapact.yaml`. The examples do not repeat `--operational-history` on every deployment. -GitHub Environment protection is used as the human production approval surface. Until SemaPact has a provider-native approval-ingestion command, the examples explicitly project a REVIEW bundle into the generic `semapact approval record` CLI before deployment. +The examples use two distinct GitHub Environment boundaries. `contract-release` supplies human PUBLISH approval for REVIEW releases, recorded with `semapact release approve`. `production` protects whether the deployment job may run; SemaPact does not convert the finalized ContractRelease into DEPLOY authorization. + +`release finalize --release-out` publishes the immutable ContractRelease as a pipeline artifact. Deployment jobs consume that artifact directly with `deployment assess --release`; Git history remains the governance ledger rather than the job-to-job transport. From c053a6a6dd8a7ce694373e7cf2eba91cdf45adc8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:27:41 +1000 Subject: [PATCH 269/616] fix(cli): remove obsolete deployment authorization result --- semapact/interfaces/commands/deployment_cmd.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 0c9552a9..2c0c1d79 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -306,13 +306,13 @@ def _bundle_text( lines = [ f"Contract: {bundle.deployment_source.contract_id}@" f"{bundle.deployment_source.contract_version}", - f"Mode: {'release' if bundle.release else 'candidate'}", + f"Mode: {'release' if bundle.is_release else 'candidate'}", f"Target: {plan.target.platform}/{plan.target.runtime_target}", f"Deployment plan: {plan.deployment_plan_id}", f"Bundle digest: {bundle.bundle_digest}", "Execution authority: none (CI/read-only bundle)", ] - if bundle.release: + if bundle.is_release: assert bundle.contract_release is not None lines.append( f"Contract release: {bundle.contract_release.contract_release_id}" @@ -343,7 +343,7 @@ def _deployment_result_text(result) -> str: lines = [ f"Bundle digest: {result.bundle_digest}", f"Deployment plan: {result.deployment_plan_id}", - f"Authorization: {result.authorization_id}", + "Execution authority: surrounding protected deployment context", *( [f"Contract release: {result.contract_release_id}"] if result.contract_release_id is not None From 5ba7339acd53a67fc9c0637cc3a524d24162bdc4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:28:05 +1000 Subject: [PATCH 270/616] docs(readme): align top-level flow with release deployment separation --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c5cf059f..27b3c98c 100644 --- a/README.md +++ b/README.md @@ -73,20 +73,25 @@ The exact governed decision then feeds either candidate deployment or an explici GovernanceDecision → ChangeSet ├─ candidate deployment - │ → DeploymentSourceSnapshot + │ → DeploymentSourceSnapshot(source_kind=candidate) │ → DeploymentPlan │ → DeploymentBundle │ - └─ formal release (--release) + └─ formal release → ReleasePlan → VersionResolution → ReleaseSnapshot + → ReleaseBundle + → ContractRelease + ↓ + + target + ↓ + DeploymentSourceSnapshot(source_kind=contract_release) → DeploymentPlan → DeploymentBundle - → ContractRelease ``` -Release approval and runtime authorization remain explicit side-effect boundaries. +Release approval protects formal publication. Runtime deployment permission belongs to the surrounding protected execution context; ContractRelease is provenance, not DEPLOY authorization. For production assurance: From 1df194802c8490b89f6cbbf532ae19cc7825c1a9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sun, 20 Sep 2026 22:30:47 +1000 Subject: [PATCH 271/616] docs(contractops): separate publish approval from deployment execution --- docs/contractops_phases.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/contractops_phases.md b/docs/contractops_phases.md index 283fd7cd..46244aef 100644 --- a/docs/contractops_phases.md +++ b/docs/contractops_phases.md @@ -195,25 +195,28 @@ Git, storage, and other release-artifact publication behavior belongs in adapter ## DEPLOY -DEPLOY mutates a runtime toward one exact target-specific `DeploymentPlan`. It is downstream of governance and may consume either: +DEPLOY converges one runtime toward an exact target-specific `DeploymentPlan`. The desired state comes from one immutable `DeploymentSourceSnapshot`: -- a candidate deployment source (`release=false`), with no release version/approval/history; or -- a formal release source (`release=true`), with exact release/version provenance and REVIEW approval when required. +- `source_kind=candidate` for validation/test deployment without a formal release; +- `source_kind=contract_release` for deployment of an already-finalized `ContractRelease`. -Candidate REVIEW deployments are allowed as non-release validation/test deployments without creating release approval evidence. `BLOCK` always fails closed. +Candidate `BLOCK` decisions fail closed. Candidate `REVIEW` may still be deployed for non-release validation/test because this path creates no formal release fact or release approval. -For formal release mode, the new `ContractRelease` is the durable Git governance fact. Runtime deployment history is operational telemetry and is not written to Git by default. +Formal REVIEW approval belongs to the earlier PUBLISH boundary. A `ContractRelease` proves what was released; it does not grant permission to mutate a runtime. Whether `semapact deployment deploy` may run is controlled by the surrounding protected execution context, such as a GitHub or Azure DevOps Environment. -Provider-specific execution stays behind the deployment adapter. For Databricks, a formal release that reaches `IN_SYNC` also projects SemaPact-owned release/version provenance into Unity Catalog tags. +The deployment adapter validates exact plan/source binding, re-observes runtime, derives a fresh deterministic preview, checks runtime freshness, applies safe operations, then verifies convergence. For Databricks, a finalized release that reaches `IN_SYNC` also projects SemaPact-owned release/version provenance into Unity Catalog tags. + +Runtime deployment history is optional operational telemetry and is not written to Git by default. See [`deployment_plans.md`](deployment_plans.md) for the complete candidate/release CLI flow, operational-history backends, Databricks tags, and convergence semantics. + ## Failure semantics -ContractOps distinguishes invalid context from denied authorization: +ContractOps distinguishes invalid release context from denied publication authorization: -- mismatched revision/artifact/operation context → `ReleaseValidationError`; -- a valid context whose explicit authorization is denied → `ContractOpsAuthorizationError`; -- external publishers or runtime adapters are never invoked when authorization validation fails. +- mismatched release revision/artifact context → `ReleaseValidationError`; +- a valid formal release whose required PUBLISH approval is denied/missing → `ContractOpsAuthorizationError`; +- runtime deployment fails closed on invalid source/plan provenance, stale runtime evidence, or unsupported provider transitions. Unexpected publisher/runtime failures are not converted into governance decisions; they propagate as execution failures. From ec50cb186ab75c2b445a0fdccd1afaf2df91cdc9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:57:07 +1000 Subject: [PATCH 272/616] refactor(history): remove derived deployment authorization telemetry --- semapact/history/operational.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/semapact/history/operational.py b/semapact/history/operational.py index a2dd4ab0..46162f63 100644 --- a/semapact/history/operational.py +++ b/semapact/history/operational.py @@ -28,16 +28,14 @@ class OperationalDeploymentEvent(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") event_id: str - event_version: Literal["1"] = "1" + event_version: Literal["2"] = "2" bundle_digest: str - release: bool contract_release_id: str | None = None contract_id: str contract_version: str revision_ref: str deployment_plan_id: str deployment_preview_id: str | None = None - deployment_authorization_id: str | None = None platform: str runtime_target: str source_reference: str @@ -68,7 +66,6 @@ def _require_text(cls, value: str) -> str: @field_validator( "contract_release_id", "deployment_preview_id", - "deployment_authorization_id", "error_message", ) @classmethod @@ -101,14 +98,12 @@ def _validate_event(self) -> "OperationalDeploymentEvent": expected = compute_operational_deployment_event_id( event_version=self.event_version, bundle_digest=self.bundle_digest, - release=self.release, contract_release_id=self.contract_release_id, contract_id=self.contract_id, contract_version=self.contract_version, revision_ref=self.revision_ref, deployment_plan_id=self.deployment_plan_id, deployment_preview_id=self.deployment_preview_id, - deployment_authorization_id=self.deployment_authorization_id, platform=self.platform, runtime_target=self.runtime_target, source_reference=self.source_reference, @@ -138,14 +133,12 @@ def record_deployment(self, event: OperationalDeploymentEvent) -> None: ... def build_operational_deployment_event( *, bundle_digest: str, - release: bool, contract_release_id: str | None, contract_id: str, contract_version: str, revision_ref: str, deployment_plan_id: str, deployment_preview_id: str | None, - deployment_authorization_id: str | None, platform: str, runtime_target: str, source_reference: str, @@ -157,16 +150,14 @@ def build_operational_deployment_event( ) -> OperationalDeploymentEvent: normalized_platform = platform.strip().casefold() event_id = compute_operational_deployment_event_id( - event_version="1", + event_version="2", bundle_digest=bundle_digest, - release=release, contract_release_id=contract_release_id, contract_id=contract_id, contract_version=contract_version, revision_ref=revision_ref, deployment_plan_id=deployment_plan_id, deployment_preview_id=deployment_preview_id, - deployment_authorization_id=deployment_authorization_id, platform=normalized_platform, runtime_target=runtime_target, source_reference=source_reference, @@ -180,16 +171,14 @@ def build_operational_deployment_event( ) return OperationalDeploymentEvent( event_id=event_id, - event_version="1", + event_version="2", bundle_digest=bundle_digest, - release=release, contract_release_id=contract_release_id, contract_id=contract_id, contract_version=contract_version, revision_ref=revision_ref, deployment_plan_id=deployment_plan_id, deployment_preview_id=deployment_preview_id, - deployment_authorization_id=deployment_authorization_id, platform=normalized_platform, runtime_target=runtime_target, source_reference=source_reference, From 690b4b1d99f519e6d99eaa42024d4f939ccce4a8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:57:25 +1000 Subject: [PATCH 273/616] refactor(deployment): centralize optional operational telemetry --- .../services/deployment_workflow.py | 116 ++++++++++-------- 1 file changed, 64 insertions(+), 52 deletions(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index 3792d210..03555a76 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -165,36 +165,20 @@ def deploy( ), ) except Exception as exc: - if operational_history is not None: - operational_history.record_deployment( - build_operational_deployment_event( - bundle_digest=bundle.bundle_digest, - release=bundle.is_release, - contract_release_id=( - release_record.contract_release_id - if release_record is not None - else None - ), - contract_id=bundle.deployment_plan.contract_id, - contract_version=bundle.deployment_plan.contract_version, - revision_ref=bundle.deployment_plan.revision_ref, - deployment_plan_id=bundle.deployment_plan.deployment_plan_id, - deployment_preview_id=( - fresh_preview.deployment_preview_id - if fresh_preview is not None - else None - ), - deployment_authorization_id=None, - platform=bundle.deployment_plan.target.platform, - runtime_target=bundle.deployment_plan.target.runtime_target, - source_reference=bundle.deployment_plan.target.source_reference, - status="FAILED", - reconciliation_status=status, - started_at=started_at, - completed_at=datetime.now(timezone.utc), - error_message=str(exc) or type(exc).__name__, - ) - ) + _record_operational_event( + operational_history, + bundle=bundle, + contract_release=release_record, + deployment_preview_id=( + fresh_preview.deployment_preview_id + if fresh_preview is not None + else None + ), + status="FAILED", + reconciliation_status=status, + started_at=started_at, + error_message=str(exc) or type(exc).__name__, + ) raise assert fresh_preview is not None @@ -213,26 +197,54 @@ def deploy( status=status, review_preview_changed=fresh_preview != bundle.review_preview, ) - if operational_history is not None: - operational_history.record_deployment( - build_operational_deployment_event( - bundle_digest=bundle.bundle_digest, - release=bundle.is_release, - contract_release_id=result.contract_release_id, - contract_id=bundle.deployment_plan.contract_id, - contract_version=bundle.deployment_plan.contract_version, - revision_ref=bundle.deployment_plan.revision_ref, - deployment_plan_id=bundle.deployment_plan.deployment_plan_id, - deployment_preview_id=fresh_preview.deployment_preview_id, - deployment_authorization_id=None, - platform=bundle.deployment_plan.target.platform, - runtime_target=bundle.deployment_plan.target.runtime_target, - source_reference=bundle.deployment_plan.target.source_reference, - status="SUCCEEDED", - reconciliation_status=status, - started_at=started_at, - completed_at=datetime.now(timezone.utc), - error_message=None, - ) - ) + _record_operational_event( + operational_history, + bundle=bundle, + contract_release=release_record, + deployment_preview_id=fresh_preview.deployment_preview_id, + status="SUCCEEDED", + reconciliation_status=status, + started_at=started_at, + error_message=None, + ) return result + + +def _record_operational_event( + sink: OperationalHistorySink | None, + *, + bundle: DeploymentBundle, + contract_release: ContractRelease | None, + deployment_preview_id: str | None, + status: str, + reconciliation_status: RuntimeDriftStatus | None, + started_at: datetime, + error_message: str | None, +) -> None: + """Persist optional deployment telemetry without influencing execution semantics.""" + if sink is None: + return + plan = bundle.deployment_plan + sink.record_deployment( + build_operational_deployment_event( + bundle_digest=bundle.bundle_digest, + contract_release_id=( + contract_release.contract_release_id + if contract_release is not None + else None + ), + contract_id=plan.contract_id, + contract_version=plan.contract_version, + revision_ref=plan.revision_ref, + deployment_plan_id=plan.deployment_plan_id, + deployment_preview_id=deployment_preview_id, + platform=plan.target.platform, + runtime_target=plan.target.runtime_target, + source_reference=plan.target.source_reference, + status=status, + reconciliation_status=reconciliation_status, + started_at=started_at, + completed_at=datetime.now(timezone.utc), + error_message=error_message, + ) + ) From 222992c622e7ba72e853bb82e2498da8b9fa03e4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:57:36 +1000 Subject: [PATCH 274/616] test(history): align operational event with canonical deployment telemetry --- tests/test_operational_history.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_operational_history.py b/tests/test_operational_history.py index 569e0e71..c8256a12 100644 --- a/tests/test_operational_history.py +++ b/tests/test_operational_history.py @@ -16,14 +16,12 @@ def _event(): return build_operational_deployment_event( bundle_digest="sha256:" + ("a" * 64), - release=False, contract_release_id=None, contract_id="orders-product", contract_version="1.2.3", revision_ref="git:abc123", deployment_plan_id="plan-1", deployment_preview_id="preview-1", - deployment_authorization_id="auth-1", platform="databricks", runtime_target="main.sales", source_reference="https://workspace.example", @@ -46,7 +44,7 @@ def test_sqlite_operational_history_is_idempotent(tmp_path) -> None: assert isinstance(sink, SQLiteOperationalHistorySink) event = _event() - assert event.event_version == "1" + assert event.event_version == "2" sink.record_deployment(event) sink.record_deployment(event) From f7c7a616a27ab336fdd700f29abf0f9869dcb4b3 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:57:39 +1000 Subject: [PATCH 275/616] test(deployment): keep telemetry free of derived authority state --- tests/test_deployment_workflow_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 65ae8d24..236c2494 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -371,8 +371,8 @@ def test_configured_operational_history_records_failed_candidate_deployment() -> assert len(sink.events) == 1 event = sink.events[0] assert event.status == "FAILED" - assert event.release is False assert event.contract_release_id is None + assert not hasattr(event, "deployment_authorization_id") assert event.reconciliation_status is None From 4ba8a7419e344c8269236c02b17877adf6996850 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:57:45 +1000 Subject: [PATCH 276/616] style(deployment): type operational event outcome explicitly --- semapact/application/services/deployment_workflow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index 03555a76..40016083 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import date, datetime, timezone +from typing import Literal from open_data_contract_standard.model import OpenDataContractStandard @@ -216,7 +217,7 @@ def _record_operational_event( bundle: DeploymentBundle, contract_release: ContractRelease | None, deployment_preview_id: str | None, - status: str, + status: Literal["SUCCEEDED", "FAILED"], reconciliation_status: RuntimeDriftStatus | None, started_at: datetime, error_message: str | None, From ac6e25d74f8191ccaab35971c0283992055f890d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:58:10 +1000 Subject: [PATCH 277/616] refactor(deployment): keep legacy authorization out of adapter port --- semapact/deployment/adapters.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/semapact/deployment/adapters.py b/semapact/deployment/adapters.py index d4f69d21..6c50dc8d 100644 --- a/semapact/deployment/adapters.py +++ b/semapact/deployment/adapters.py @@ -7,7 +7,6 @@ from typing import Protocol, runtime_checkable from semapact.deployment.models import ( - DeploymentAuthorization, DeploymentPlan, DeploymentPreview, ) @@ -65,13 +64,3 @@ def apply( ) -> None: """Apply one exact preview after the application boundary authorizes execution.""" raise NotImplementedError - - @abstractmethod - def execute( - self, - plan: DeploymentPlan, - preview: DeploymentPreview, - authorization: DeploymentAuthorization, - ) -> None: - """Compatibility entrypoint for legacy in-process authorization artifacts.""" - raise NotImplementedError From 14de6e6b1b460cbf2d2251bdce6a694a78e8ae7b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:58:13 +1000 Subject: [PATCH 278/616] refactor(deployment): isolate legacy authorization in application seam --- semapact/application/services/deployment.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/semapact/application/services/deployment.py b/semapact/application/services/deployment.py index 765940df..116887ab 100644 --- a/semapact/application/services/deployment.py +++ b/semapact/application/services/deployment.py @@ -12,8 +12,9 @@ DeploymentTarget, build_deployment_plan, build_deployment_plan_from_source, + validate_deployment_authorization_identity, ) -from semapact.exceptions import ValidationError +from semapact.exceptions import ContractOpsAuthorizationError, ValidationError from semapact.reconciliation import ReconciliationResult @@ -57,8 +58,22 @@ def execute( *, adapter: DeploymentAdapter, ) -> None: + """Compatibility wrapper for legacy in-process authorization callers.""" _validate_component_key(adapter.key, plan.target.platform, "deployment adapter") - adapter.execute(plan, preview, authorization) + validate_deployment_authorization_identity(authorization) + if not authorization.allowed: + raise ContractOpsAuthorizationError( + "DeploymentAuthorization is not allowed" + ) + if authorization.deployment_plan_id != plan.deployment_plan_id: + raise ContractOpsAuthorizationError( + "DeploymentAuthorization is not bound to this DeploymentPlan" + ) + if authorization.source_snapshot_id != plan.source_snapshot_id: + raise ContractOpsAuthorizationError( + "DeploymentAuthorization source does not match DeploymentPlan" + ) + adapter.apply(plan, preview) def verify( self, From 81df4a2b0bc93a9129a2234fc5f50d05049409eb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:58:20 +1000 Subject: [PATCH 279/616] fix(deployment): import compatibility validator from model seam --- semapact/application/services/deployment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/application/services/deployment.py b/semapact/application/services/deployment.py index 116887ab..7661e2c4 100644 --- a/semapact/application/services/deployment.py +++ b/semapact/application/services/deployment.py @@ -12,8 +12,8 @@ DeploymentTarget, build_deployment_plan, build_deployment_plan_from_source, - validate_deployment_authorization_identity, ) +from semapact.deployment.models import validate_deployment_authorization_identity from semapact.exceptions import ContractOpsAuthorizationError, ValidationError from semapact.reconciliation import ReconciliationResult From 216e734d666452e3a3aa3c18b14756aac3cf6868 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:58:53 +1000 Subject: [PATCH 280/616] refactor(config): make pydantic the schema source of truth --- semapact/core/config_schema.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/semapact/core/config_schema.py b/semapact/core/config_schema.py index a849e77f..6b5e0b31 100644 --- a/semapact/core/config_schema.py +++ b/semapact/core/config_schema.py @@ -19,7 +19,7 @@ class SQLiteOperationalHistoryConfig(_OperationalHistoryConfig): """Local SQLite operational history configuration.""" backend: Literal["sqlite"] - path: str + path: str = Field(min_length=1) @field_validator("path") @classmethod @@ -37,7 +37,7 @@ class DeltaOperationalHistoryConfig(_OperationalHistoryConfig): """Delta Lake operational history configuration.""" backend: Literal["delta"] - table_uri: str + table_uri: str = Field(min_length=1) @field_validator("table_uri") @classmethod @@ -68,7 +68,13 @@ class HistoryConfig(BaseModel): class SemaPactConfigSchema(BaseModel): """Incremental root schema for project/global SemaPact configuration.""" - model_config = ConfigDict(extra="allow") + model_config = ConfigDict( + extra="allow", + json_schema_extra={ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://semapact.org/schemas/semapact-config.schema.json", + }, + ) history: HistoryConfig | None = None @@ -88,3 +94,8 @@ def operational_history_uri_from_config(value: object) -> str | None: """Return the normalized sink URI represented by typed configuration.""" config = parse_operational_history_config(value) return config.as_uri() if config is not None else None + + +def published_config_json_schema() -> dict[str, object]: + """Return the canonical machine-readable SemaPact configuration schema.""" + return SemaPactConfigSchema.model_json_schema() From f61753df53ccc35e3243c7f2431bbaeec1791112 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:59:03 +1000 Subject: [PATCH 281/616] docs(config): regenerate published schema from pydantic contract --- schemas/semapact-config.schema.json | 117 ++++++++++++++++++---------- 1 file changed, 75 insertions(+), 42 deletions(-) diff --git a/schemas/semapact-config.schema.json b/schemas/semapact-config.schema.json index 25ed399c..8fcd92ea 100644 --- a/schemas/semapact-config.schema.json +++ b/schemas/semapact-config.schema.json @@ -1,68 +1,101 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://semapact.org/schemas/semapact-config.schema.json", - "title": "SemaPact configuration", - "type": "object", - "additionalProperties": true, - "properties": { - "history": { - "type": [ - "object", - "null" + "$defs": { + "DeltaOperationalHistoryConfig": { + "additionalProperties": false, + "description": "Delta Lake operational history configuration.", + "properties": { + "backend": { + "const": "delta", + "title": "Backend", + "type": "string" + }, + "table_uri": { + "minLength": 1, + "title": "Table Uri", + "type": "string" + } + }, + "required": [ + "backend", + "table_uri" ], + "title": "DeltaOperationalHistoryConfig", + "type": "object" + }, + "HistoryConfig": { "additionalProperties": false, + "description": "Typed history configuration while unrelated legacy config remains extensible.", "properties": { "operational": { - "oneOf": [ + "anyOf": [ { - "$ref": "#/$defs/SQLiteOperationalHistoryConfig" - }, - { - "$ref": "#/$defs/DeltaOperationalHistoryConfig" + "discriminator": { + "mapping": { + "delta": "#/$defs/DeltaOperationalHistoryConfig", + "sqlite": "#/$defs/SQLiteOperationalHistoryConfig" + }, + "propertyName": "backend" + }, + "oneOf": [ + { + "$ref": "#/$defs/SQLiteOperationalHistoryConfig" + }, + { + "$ref": "#/$defs/DeltaOperationalHistoryConfig" + } + ] }, { "type": "null" } ], - "default": null + "default": null, + "title": "Operational" } - } - } - }, - "$defs": { + }, + "title": "HistoryConfig", + "type": "object" + }, "SQLiteOperationalHistoryConfig": { - "type": "object", "additionalProperties": false, - "required": [ - "backend", - "path" - ], + "description": "Local SQLite operational history configuration.", "properties": { "backend": { - "const": "sqlite" + "const": "sqlite", + "title": "Backend", + "type": "string" }, "path": { - "type": "string", - "minLength": 1 + "minLength": 1, + "title": "Path", + "type": "string" } - } - }, - "DeltaOperationalHistoryConfig": { - "type": "object", - "additionalProperties": false, + }, "required": [ "backend", - "table_uri" + "path" ], - "properties": { - "backend": { - "const": "delta" + "title": "SQLiteOperationalHistoryConfig", + "type": "object" + } + }, + "$id": "https://semapact.org/schemas/semapact-config.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "Incremental root schema for project/global SemaPact configuration.", + "properties": { + "history": { + "anyOf": [ + { + "$ref": "#/$defs/HistoryConfig" }, - "table_uri": { - "type": "string", - "minLength": 1 + { + "type": "null" } - } + ], + "default": null } - } + }, + "title": "SemaPactConfigSchema", + "type": "object" } From 86045f0dd8c1d440ba9741fc9fdf9fa7587aee49 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:59:11 +1000 Subject: [PATCH 282/616] test(config): prevent published schema drift --- tests/test_operational_history_config.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_operational_history_config.py b/tests/test_operational_history_config.py index 76369ec5..3ae13fc1 100644 --- a/tests/test_operational_history_config.py +++ b/tests/test_operational_history_config.py @@ -11,6 +11,7 @@ SemaPactConfigSchema, operational_history_uri_from_config, parse_operational_history_config, + published_config_json_schema, ) @@ -70,12 +71,9 @@ def test_root_config_schema_exposes_operational_backend_discriminator() -> None: assert "discriminator" in rendered -def test_published_config_json_schema_covers_operational_history() -> None: +def test_published_config_json_schema_matches_pydantic_source_of_truth() -> None: schema = json.loads( Path("schemas/semapact-config.schema.json").read_text(encoding="utf-8") ) - operational = schema["properties"]["history"]["properties"]["operational"] - assert len(operational["oneOf"]) == 3 - assert "SQLiteOperationalHistoryConfig" in schema["$defs"] - assert "DeltaOperationalHistoryConfig" in schema["$defs"] + assert schema == published_config_json_schema() From 9a5414a2c77ecab11cc3e4b69e9540ddf7e288b5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:59:32 +1000 Subject: [PATCH 283/616] docs(deployment): align execution trust boundary with canonical flow --- docs/deployment_plans.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 8f61ece9..e0423446 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -421,23 +421,20 @@ The Databricks principal running CD must have the Unity Catalog privileges neede Tag mutation is a provider side effect and therefore requires a Databricks SQL warehouse even when the schema deployment itself resolves to `NO_OP`. A tag-write failure fails the release deployment command rather than silently claiming the runtime is fully projected. -## Authorization scope +## Execution trust boundary -Candidate and release deployment use different authorization semantics: +Candidate and finalized-release deployments use different provenance, but neither turns release state into runtime execution authority: ```text candidate deployment → exact GovernanceDecision + candidate source snapshot → BLOCK fails closed -→ no release ApprovalRecord formal release deployment -→ exact ContractOps release context -→ REVIEW requires exact approval -→ exact DeploymentPlan authorization +→ exact finalized ContractRelease provenance ``` -In both modes, authorization binds the exact source snapshot and target-specific DeploymentPlan before runtime mutation. +Whether runtime mutation may execute is decided by the surrounding protected CD context, such as a GitHub Environment or Azure DevOps Environment. SemaPact validates the exact bundle, source/plan linkage, fresh runtime observation, deterministic preview, and convergence; it does not manufacture DEPLOY authority from a ContractRelease. ## Determinism From bd950488690e952dd85579e5e90d4ed3b3ef2fa9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:59:34 +1000 Subject: [PATCH 284/616] refactor(deployment): narrow canonical public exports --- semapact/deployment/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index 402b5203..cbb8eab4 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -1,4 +1,8 @@ -"""Provider-neutral deployment planning, orchestration and authorization boundary.""" +"""Provider-neutral deployment planning and orchestration boundary. + +Legacy authorization symbols remain importable for compatibility but are not part of +the canonical public surface. +""" from semapact.deployment.adapters import ( DeploymentAdapter, @@ -50,7 +54,6 @@ "DeploymentAdapter", "RuntimeReleaseMetadata", "RuntimeReleaseMetadataProjector", - "DeploymentAuthorization", "DeploymentExecutionConfig", "DeploymentOrchestrator", "DeploymentPlan", @@ -62,8 +65,6 @@ "NativeOperationKind", "TransitionCompiler", "SchemaTransitionPlanner", - "authorize_candidate_deployment", - "authorize_deployment", "validate_candidate_deployment_context", "validate_contract_release_deployment_context", "build_deployment_actions", @@ -71,7 +72,6 @@ "build_deployment_plan_from_source", "build_candidate_deployment_source", "build_contract_release_deployment_source", - "build_release_deployment_source", "DeploymentSourceSnapshot", "verify_deployment_convergence", ] From e5aae9aef3cc89a54b18a69cc8fb483073364a6d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 06:59:43 +1000 Subject: [PATCH 285/616] refactor(history): narrow canonical governance exports --- semapact/history/__init__.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/semapact/history/__init__.py b/semapact/history/__init__.py index eb42ea65..2f3d3935 100644 --- a/semapact/history/__init__.py +++ b/semapact/history/__init__.py @@ -1,7 +1,9 @@ """Durable governance-history persistence boundary. -History stores canonical domain artifacts without becoming a second source of +History stores canonical governance artifacts without becoming a second source of revision, governance, ContractOps, deployment, or reconciliation semantics. +Legacy Git-backed operational-history symbols remain importable for compatibility +but are intentionally excluded from the canonical public export surface. """ from semapact.history.models import ( @@ -48,12 +50,6 @@ "ContractRevisionHistoryRepository", "ContractRevisionSourceHistoryRepository", "DecisionHistoryRepository", - "DeploymentAuthorizationHistoryRepository", - "DeploymentPlanHistoryRepository", - "DeploymentPreviewHistoryRepository", - "DeploymentRecord", - "DeploymentRecordHistoryRepository", - "DeploymentStatus", "HistoryConflictError", "HistoryCorruptionError", "HistoryIntegrityIssueCode", @@ -62,12 +58,6 @@ "HistoryRepositoryError", "HistoryStorageIntegrityIssue", "ReleasePlanHistoryRepository", - "ReleaseRecord", - "ReleaseRecordHistoryRepository", - "RuntimeObservationHistoryRepository", - "RuntimeObservationRecord", - "RuntimeReconciliationHistoryRepository", - "RuntimeReconciliationRecord", ] From 3b6535b463ab3f4f61ca3dabd7391c3eae90375c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 07:00:55 +1000 Subject: [PATCH 286/616] docs(deployment): mark authorization helpers as compatibility seam --- semapact/deployment/authorization.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index 3b4d7947..c58889e2 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -1,4 +1,8 @@ -"""Bind release-level DEPLOY authorization to one exact DeploymentPlan.""" +"""Legacy in-process deployment authorization plus canonical provenance validation. + +Canonical bundle-driven CD uses the validate_* context functions below and leaves +runtime execution permission to the surrounding protected deployment context. +""" from __future__ import annotations From 612aa28bc68d5507153f96ab5537b19effac511f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 07:00:59 +1000 Subject: [PATCH 287/616] docs(history): mark Git operational records as legacy --- semapact/history/models.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/semapact/history/models.py b/semapact/history/models.py index 558ae500..e16d5b04 100644 --- a/semapact/history/models.py +++ b/semapact/history/models.py @@ -143,6 +143,11 @@ def _validate_provenance_pairs(self) -> ReleaseRecord: # Backward-compatible persistence name. Canonical ownership lives in ContractOps. ContractReleaseRecord = ContractRelease + + +# Legacy Git-backed operational records. Canonical high-frequency deployment +# telemetry lives in semapact.history.operational and is never written to Git by +# the bundle-driven workflow. class DeploymentStatus(str, Enum): """Terminal provider-execution outcome for one deployment occurrence.""" From 400a0de3bb896fb0fa46c6ea59b2aedcf4ec6f6e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 07:01:01 +1000 Subject: [PATCH 288/616] style(release): consolidate ContractOps imports --- semapact/application/services/release_workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/application/services/release_workflow.py b/semapact/application/services/release_workflow.py index d7abcc47..58b39bfd 100644 --- a/semapact/application/services/release_workflow.py +++ b/semapact/application/services/release_workflow.py @@ -15,13 +15,13 @@ ) from semapact.application.services.release_planning import ReleasePlanningService from semapact.contractops import ( + ContractRelease, ReviewEvidenceAction, authorize_contract_operation, build_release_snapshot, ) from semapact.exceptions import ContractOpsAuthorizationError, ValidationError from semapact.governance import DecisionResult, GovernanceOperation -from semapact.contractops import ContractRelease from semapact.history import ContractReleaseHistoryRepository From a9ede0f7b0627e5a6a1efe843c781e620728c17b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 07:02:53 +1000 Subject: [PATCH 289/616] test(deployment): keep legacy execute behind canonical apply --- tests/test_deployment_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_deployment_service.py b/tests/test_deployment_service.py index 57f088e7..1af7f49e 100644 --- a/tests/test_deployment_service.py +++ b/tests/test_deployment_service.py @@ -214,7 +214,7 @@ def test_apply_delegates_exact_plan_and_preview_without_internal_authorization() assert adapter.executed == (plan, preview, None) -def test_execute_delegates_exact_canonical_artifacts() -> None: +def test_execute_compatibility_wrapper_validates_then_uses_canonical_apply() -> None: plan = _plan() preview = _preview(plan) authorization = _authorization(plan) @@ -228,7 +228,7 @@ def test_execute_delegates_exact_canonical_artifacts() -> None: ) assert adapter.execute_calls == 1 - assert adapter.executed == (plan, preview, authorization) + assert adapter.executed == (plan, preview, None) def test_verify_delegates_to_same_adapter_entrypoint() -> None: From 93b8985ecba98c087884b344197b45e050c219ef Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 07:04:32 +1000 Subject: [PATCH 290/616] refactor(deployment): keep release planning lineage out of canonical source --- semapact/deployment/source.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/semapact/deployment/source.py b/semapact/deployment/source.py index 0efbf877..954bea7a 100644 --- a/semapact/deployment/source.py +++ b/semapact/deployment/source.py @@ -69,10 +69,20 @@ def _validate_source(self) -> "DeploymentSourceSnapshot": raise ValueError( "Candidate deployment source cannot contain release provenance" ) - elif self.release_id is None or self.release_plan_id is None: - raise ValueError( - "Released deployment source requires release provenance" - ) + elif self.source_kind == "release_snapshot": + if self.release_id is None or self.release_plan_id is None: + raise ValueError( + "Legacy ReleaseSnapshot source requires release provenance" + ) + else: + if self.release_id is None: + raise ValueError( + "ContractRelease source requires contract_release_id provenance" + ) + if self.release_plan_id is not None: + raise ValueError( + "Canonical ContractRelease source must not duplicate release_plan_id" + ) expected = compute_deployment_source_id( source_kind=self.source_kind, @@ -185,7 +195,7 @@ def build_contract_release_deployment_source( contract_version=release.contract_version, contract_json=contract_json, release_id=release.contract_release_id, - release_plan_id=release.release_plan_id, + release_plan_id=None, ) return DeploymentSourceSnapshot( source_snapshot_id=source_id, @@ -195,7 +205,7 @@ def build_contract_release_deployment_source( contract_version=release.contract_version, contract_json=contract_json, release_id=release.contract_release_id, - release_plan_id=release.release_plan_id, + release_plan_id=None, ) From ccc3136c677f9af6f71cc7cc433a7252bc82c509 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 07:04:35 +1000 Subject: [PATCH 291/616] refactor(deployment): remove redundant release plan lineage from v5 plan --- semapact/deployment/models.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index 0847616a..e71f916f 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -215,12 +215,18 @@ def _validate_action_order_and_identity(self) -> "DeploymentPlan": if len(governed_assets) != len(set(governed_assets)): raise ValueError("DeploymentPlan cannot contain duplicate governed assets") - has_release_id = self.release_id is not None - has_release_plan = self.release_plan_id is not None - if has_release_id != has_release_plan: - raise ValueError( - "DeploymentPlan release_id and release_plan_id must be provided together" - ) + if self.plan_version == "5": + if self.release_plan_id is not None: + raise ValueError( + "Canonical DeploymentPlan must not duplicate release_plan_id" + ) + else: + has_release_id = self.release_id is not None + has_release_plan = self.release_plan_id is not None + if has_release_id != has_release_plan: + raise ValueError( + "Legacy DeploymentPlan release_id and release_plan_id must be provided together" + ) validate_deployment_plan_identity(self) return self @@ -474,9 +480,13 @@ def compute_deployment_plan_id( has_release_id = release_id is not None has_release_plan = release_plan_id is not None - if has_release_id != has_release_plan: + if plan_version == "4" and has_release_id != has_release_plan: + raise ValueError( + "Legacy DeploymentPlan release_id and release_plan_id must be provided together" + ) + if plan_version == "5" and release_plan_id is not None: raise ValueError( - "DeploymentPlan release_id and release_plan_id must be provided together" + "Canonical DeploymentPlan must not duplicate release_plan_id" ) derived_release = has_release_id if plan_version == "4" and release is not None and bool(release) != derived_release: @@ -490,7 +500,7 @@ def compute_deployment_plan_id( "revision_ref": resolved_revision, "contract_version": resolved_version, "release_id": release_id, - "release_plan_id": release_plan_id, + "release_plan_id": release_plan_id if plan_version == "4" else None, "target": target.model_dump(mode="json"), "actions": [action.model_dump(mode="json") for action in actions], "plan_version": plan_version, From 82265e4a49084a6249374a878f717d6dec197164 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 07:04:42 +1000 Subject: [PATCH 292/616] refactor(deployment): omit redundant release plan from canonical plan --- semapact/deployment/planner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/deployment/planner.py b/semapact/deployment/planner.py index ede3d70c..c73b62c0 100644 --- a/semapact/deployment/planner.py +++ b/semapact/deployment/planner.py @@ -101,7 +101,7 @@ def build_deployment_plan_from_source( revision_ref=source.revision_ref, contract_version=source.contract_version, release_id=source.release_id, - release_plan_id=source.release_plan_id, + release_plan_id=source.release_plan_id if source.source_kind == "release_snapshot" else None, target=target, actions=ordered_actions, plan_version="5", @@ -115,7 +115,7 @@ def build_deployment_plan_from_source( target=target, actions=ordered_actions, release_id=source.release_id, - release_plan_id=source.release_plan_id, + release_plan_id=source.release_plan_id if source.source_kind == "release_snapshot" else None, plan_version="5", ) From 83353ec3d1336c091f24a2b89ba8a03c7f99f86b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:18:47 +1000 Subject: [PATCH 293/616] refactor(release): separate finalization from persistence --- semapact/application/services/release_workflow.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/semapact/application/services/release_workflow.py b/semapact/application/services/release_workflow.py index 58b39bfd..530bdeea 100644 --- a/semapact/application/services/release_workflow.py +++ b/semapact/application/services/release_workflow.py @@ -22,11 +22,10 @@ ) from semapact.exceptions import ContractOpsAuthorizationError, ValidationError from semapact.governance import DecisionResult, GovernanceOperation -from semapact.history import ContractReleaseHistoryRepository class ReleaseWorkflowService: - """Assess and finalize one formal contract release independently of deployment.""" + """Assess and approve target-neutral formal contract releases.""" def __init__( self, @@ -97,14 +96,17 @@ def approve( evidence_references=(bundle.bundle_digest,), ) + +class ReleaseFinalizer: + """Authorize one exact ReleaseBundle and construct its ContractRelease fact.""" + def finalize( self, bundle: ReleaseBundle, *, - release_history: ContractReleaseHistoryRepository, approval: ApprovalRecord | None = None, ) -> ContractRelease: - """Authorize and persist one formal release exactly once.""" + """Finalize domain state without persisting or materializing projections.""" evidence = None if bundle.decision.decision is DecisionResult.REVIEW: if approval is None: @@ -136,6 +138,4 @@ def finalize( "Formal release is not authorized: " f"{authorization.reason.value}" ) - release = build_contract_release(bundle) - release_history.put_contract_release(release) - return release + return build_contract_release(bundle) From b31102103d6be1e2c5fa8e0e250511f34cf78821 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:18:54 +1000 Subject: [PATCH 294/616] refactor(release): persist finalized fact at CLI boundary --- semapact/interfaces/commands/release_cmd.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index 0e67fd65..f6f97939 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -57,7 +57,7 @@ def run_release_finalize(args: argparse.Namespace) -> dict[str, Any]: """Finalize one exact release, persist ledger fact, and materialize versioned ODCS.""" from semapact.application.models.release import ReleaseBundle from semapact.application.services.release_approval import ReleaseApprovalResolver - from semapact.application.services.release_workflow import ReleaseWorkflowService + from semapact.application.services.release_workflow import ReleaseFinalizer from semapact.governance import DecisionResult from semapact.platforms.git import GitWorkingTreeHistoryRepository from semapact.utils.yaml_utils import dump_yaml @@ -68,11 +68,11 @@ def run_release_finalize(args: argparse.Namespace) -> dict[str, Any]: if bundle.decision.decision is DecisionResult.REVIEW: approval = ReleaseApprovalResolver(repository).resolve(bundle) - record = ReleaseWorkflowService().finalize( + record = ReleaseFinalizer().finalize( bundle, - release_history=repository, approval=approval, ) + repository.put_contract_release(record) output_path = dump_yaml(bundle.release_snapshot.to_contract(), args.output_contract) release_out = getattr(args, "release_out", None) if release_out: From bfe70bca5cea3bb7517d00958ad9606501b4f62c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:19:03 +1000 Subject: [PATCH 295/616] test(release): verify pure finalizer boundary --- tests/test_release_workflow_service.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/test_release_workflow_service.py b/tests/test_release_workflow_service.py index e41e5aff..94f54b41 100644 --- a/tests/test_release_workflow_service.py +++ b/tests/test_release_workflow_service.py @@ -12,7 +12,7 @@ from semapact.application.models.release import ReleaseBundle from semapact.application.services.release_approval import ReleaseApprovalResolver -from semapact.application.services.release_workflow import ReleaseWorkflowService +from semapact.application.services.release_workflow import ReleaseFinalizer, ReleaseWorkflowService from semapact.exceptions import ContractOpsAuthorizationError from semapact.governance import DecisionResult, GovernanceOperation from semapact.platforms.git import GitWorkingTreeHistoryRepository @@ -96,12 +96,10 @@ def test_review_release_finalize_requires_exact_approval(tmp_path) -> None: bundle = _bundle() repository = GitWorkingTreeHistoryRepository(tmp_path) workflow = ReleaseWorkflowService() + finalizer = ReleaseFinalizer() with pytest.raises(ContractOpsAuthorizationError, match="requires approval"): - workflow.finalize( - bundle, - release_history=repository, - ) + finalizer.finalize(bundle) approval = workflow.approve( bundle, @@ -109,11 +107,11 @@ def test_review_release_finalize_requires_exact_approval(tmp_path) -> None: recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), ) repository.put_approval_record(approval) - record = workflow.finalize( + record = finalizer.finalize( bundle, - release_history=repository, approval=approval, ) + repository.put_contract_release(record) assert record.contract_version == "1.3.0" assert record.release_snapshot_id == bundle.release_snapshot.release_snapshot_id From 2caa0fe0276d5f83c6ea0145f8cbd19b4bcf09ec Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:19:10 +1000 Subject: [PATCH 296/616] test(deployment): consume pure finalized release fact --- tests/test_deployment_workflow_service.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 236c2494..43e4e5d4 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -11,7 +11,7 @@ from pydantic import ValidationError as PydanticValidationError from semapact.application.services.deployment_workflow import DeploymentWorkflowService -from semapact.application.services.release_workflow import ReleaseWorkflowService +from semapact.application.services.release_workflow import ReleaseFinalizer, ReleaseWorkflowService from semapact.deployment import ( DeploymentAdapter, DeploymentPreview, @@ -22,7 +22,6 @@ from semapact.deployment.models import compute_deployment_preview_id from semapact.governance import DecisionResult from semapact.observation import ObservedPlatformState, with_observed_state_fingerprint -from semapact.platforms.git import GitWorkingTreeHistoryRepository from semapact.reconciliation import ReconciliationResult, RuntimeDriftStatus @@ -203,7 +202,7 @@ def _target(name: str = "sales") -> DeploymentTarget: ) -def _finalized_release(tmp_path): +def _finalized_release(): workflow = ReleaseWorkflowService() bundle = workflow.assess( _contract(name="Orders"), @@ -217,11 +216,8 @@ def _finalized_release(tmp_path): actor_reference="human:reviewer", recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), ) - repository = GitWorkingTreeHistoryRepository(tmp_path) - repository.put_approval_record(approval) - return workflow.finalize( + return ReleaseFinalizer().finalize( bundle, - release_history=repository, approval=approval, ) @@ -248,7 +244,7 @@ def test_candidate_assessment_does_not_calculate_release_version() -> None: def test_finalized_release_assessment_binds_exact_release_record(tmp_path) -> None: - release = _finalized_release(tmp_path) + release = _finalized_release() bundle = DeploymentWorkflowService().assess_release( release, target=_target(), @@ -377,7 +373,7 @@ def test_configured_operational_history_records_failed_candidate_deployment() -> def test_one_finalized_release_fans_out_to_multiple_targets(tmp_path) -> None: - release = _finalized_release(tmp_path) + release = _finalized_release() service = DeploymentWorkflowService() dev = service.assess_release( @@ -398,7 +394,7 @@ def test_one_finalized_release_fans_out_to_multiple_targets(tmp_path) -> None: def test_release_deployment_projects_finalized_version_after_in_sync(tmp_path) -> None: - release = _finalized_release(tmp_path) + release = _finalized_release() bundle = DeploymentWorkflowService().assess_release( release, target=_target(), From 5dfbb1607a193e82add166743b675011b73f40af Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:20:53 +1000 Subject: [PATCH 297/616] refactor(deployment): isolate legacy artifact compatibility --- semapact/deployment/compatibility.py | 109 +++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 semapact/deployment/compatibility.py diff --git a/semapact/deployment/compatibility.py b/semapact/deployment/compatibility.py new file mode 100644 index 00000000..f6ca24cd --- /dev/null +++ b/semapact/deployment/compatibility.py @@ -0,0 +1,109 @@ +"""Explicit compatibility adapters for legacy deployment artifacts. + +Canonical deployment models validate canonical field names only. Legacy JSON is +upgraded or serialized here at the boundary so domain models do not hide migration +behavior in Pydantic validators/serializers. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from semapact.deployment.models import DeploymentAuthorization, DeploymentPlan + + +def parse_deployment_plan_payload(payload: Mapping[str, Any]) -> DeploymentPlan: + """Adapt a legacy or canonical plan payload into DeploymentPlan.""" + value = dict(payload) + raw_version = value.get("plan_version") + if raw_version is None: + if "applied_release_id" in value: + version = "2" + elif ( + "release_id" in value + or "released_revision_ref" in value + or "selected_version" in value + ): + version = "3" + else: + return DeploymentPlan.model_validate(value) + value["plan_version"] = version + else: + version = str(raw_version) + + if version == "4": + legacy_release = value.pop("release", None) + has_release_provenance = bool( + value.get("release_id") or value.get("release_plan_id") + ) + if legacy_release is not None and bool(legacy_release) != has_release_provenance: + raise ValueError( + "Legacy DeploymentPlan release flag conflicts with release provenance" + ) + return DeploymentPlan.model_validate(value) + + if version in {"2", "3"}: + legacy_release_id = value.get("release_id") or value.get( + "applied_release_id" + ) + value.setdefault("source_snapshot_id", legacy_release_id) + value.setdefault("revision_ref", value.get("released_revision_ref")) + value.setdefault("contract_version", value.get("selected_version")) + value.setdefault("release_id", legacy_release_id) + value.pop("applied_release_id", None) + value.pop("released_revision_ref", None) + value.pop("selected_version", None) + + return DeploymentPlan.model_validate(value) + + +def serialize_deployment_plan_payload(plan: DeploymentPlan) -> dict[str, Any]: + """Serialize DeploymentPlan using its historical wire format when required.""" + payload = plan.model_dump(mode="json") + if plan.plan_version == "4": + payload["release"] = plan.is_release + return payload + if plan.plan_version not in {"2", "3"}: + return payload + + payload["released_revision_ref"] = payload.pop("revision_ref") + payload["selected_version"] = payload.pop("contract_version") + payload.pop("source_snapshot_id", None) + if plan.plan_version == "2": + payload["applied_release_id"] = payload.pop("release_id") + return payload + + +def parse_deployment_authorization_payload( + payload: Mapping[str, Any], +) -> DeploymentAuthorization: + """Adapt a legacy or canonical authorization payload.""" + value = dict(payload) + if "source_snapshot_id" not in value: + contractops_id = value.get("contract_ops_authorization_id") + release_id = value.get("applied_release_id") + if contractops_id is not None and release_id is not None: + value["source_snapshot_id"] = release_id + value["authorization_kind"] = "contractops" + value["authorization_reference"] = contractops_id + value["authorization_version"] = "1" + value.pop("contract_ops_authorization_id", None) + value.pop("applied_release_id", None) + return DeploymentAuthorization.model_validate(value) + + +def serialize_deployment_authorization_payload( + authorization: DeploymentAuthorization, +) -> dict[str, Any]: + """Serialize authorization using the v1 wire shape only when requested.""" + payload = authorization.model_dump(mode="json") + if authorization.authorization_version != "1": + return payload + payload["contract_ops_authorization_id"] = payload.pop( + "authorization_reference" + ) + payload["applied_release_id"] = payload.pop("source_snapshot_id") + payload.pop("authorization_kind", None) + payload.pop("authorization_version", None) + return payload From 686433a1fa6274dd223f0124c7596cbdd9938b54 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:21:02 +1000 Subject: [PATCH 298/616] refactor(deployment): remove hidden legacy model migration --- semapact/deployment/models.py | 92 ----------------------------------- 1 file changed, 92 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index e71f916f..dc48e44f 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -12,7 +12,6 @@ ConfigDict, Field, field_validator, - model_serializer, model_validator, ) @@ -139,51 +138,6 @@ class DeploymentPlan(DeploymentModel): release_plan_id: str | None = None plan_version: Literal["2", "3", "4", "5"] = "5" - @model_validator(mode="before") - @classmethod - def _upgrade_legacy_plan_payload(cls, value): - if not isinstance(value, dict): - return value - payload = dict(value) - raw_version = payload.get("plan_version") - if raw_version is None: - if "applied_release_id" in payload: - version = "2" - elif ( - "release_id" in payload - or "released_revision_ref" in payload - or "selected_version" in payload - ): - version = "3" - else: - return payload - payload["plan_version"] = version - else: - version = str(raw_version) - if version == "4": - legacy_release = payload.pop("release", None) - has_release_provenance = bool( - payload.get("release_id") or payload.get("release_plan_id") - ) - if legacy_release is not None and bool(legacy_release) != has_release_provenance: - raise ValueError( - "Legacy DeploymentPlan release flag conflicts with release provenance" - ) - return payload - if version not in {"2", "3"}: - return payload - - legacy_release_id = payload.get("release_id") or payload.get( - "applied_release_id" - ) - payload.setdefault("source_snapshot_id", legacy_release_id) - payload.setdefault("revision_ref", payload.get("released_revision_ref")) - payload.setdefault("contract_version", payload.get("selected_version")) - payload.setdefault("release_id", legacy_release_id) - payload.pop("applied_release_id", None) - payload.pop("released_revision_ref", None) - payload.pop("selected_version", None) - return payload @field_validator( "deployment_plan_id", @@ -231,21 +185,6 @@ def _validate_action_order_and_identity(self) -> "DeploymentPlan": validate_deployment_plan_identity(self) return self - @model_serializer(mode="wrap") - def _serialize_legacy_plan(self, handler): - payload = handler(self) - if self.plan_version == "4": - payload["release"] = self.is_release - return payload - if self.plan_version not in {"2", "3"}: - return payload - - payload["released_revision_ref"] = payload.pop("revision_ref") - payload["selected_version"] = payload.pop("contract_version") - payload.pop("source_snapshot_id", None) - if self.plan_version == "2": - payload["applied_release_id"] = payload.pop("release_id") - return payload @property def is_release(self) -> bool: @@ -344,25 +283,6 @@ class DeploymentAuthorization(DeploymentModel): authorization_reference: str authorization_version: Literal["1", "2"] = "2" - @model_validator(mode="before") - @classmethod - def _upgrade_legacy_authorization_payload(cls, value): - if not isinstance(value, dict): - return value - payload = dict(value) - if "source_snapshot_id" in payload: - return payload - contractops_id = payload.get("contract_ops_authorization_id") - release_id = payload.get("applied_release_id") - if contractops_id is None or release_id is None: - return payload - payload["source_snapshot_id"] = release_id - payload["authorization_kind"] = "contractops" - payload["authorization_reference"] = contractops_id - payload["authorization_version"] = "1" - payload.pop("contract_ops_authorization_id", None) - payload.pop("applied_release_id", None) - return payload @field_validator( "deployment_authorization_id", @@ -382,18 +302,6 @@ def _validate_identity(self) -> "DeploymentAuthorization": validate_deployment_authorization_identity(self) return self - @model_serializer(mode="wrap") - def _serialize_legacy_authorization(self, handler): - payload = handler(self) - if self.authorization_version != "1": - return payload - payload["contract_ops_authorization_id"] = payload.pop( - "authorization_reference" - ) - payload["applied_release_id"] = payload.pop("source_snapshot_id") - payload.pop("authorization_kind", None) - payload.pop("authorization_version", None) - return payload @property def contract_ops_authorization_id(self) -> str: From 6f0c6af1bf5d3364df3e22b2e18007bca7f3ad09 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:21:13 +1000 Subject: [PATCH 299/616] refactor(deployment): construct legacy plans through canonical fields --- semapact/deployment/planner.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/semapact/deployment/planner.py b/semapact/deployment/planner.py index c73b62c0..9fc86202 100644 --- a/semapact/deployment/planner.py +++ b/semapact/deployment/planner.py @@ -65,11 +65,12 @@ def build_deployment_plan( return DeploymentPlan( deployment_plan_id=deployment_plan_id, + source_snapshot_id=release_id, release_id=release_id, contract_id=release.contract_id, release_plan_id=release.release_plan_id, - released_revision_ref=release.release_revision_ref, - selected_version=release.selected_version, + revision_ref=release.release_revision_ref, + contract_version=release.selected_version, target=target, actions=ordered_actions, plan_version=plan_version, From bc38ced157788ca80a58edd8dd524472d321cb8e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:21:21 +1000 Subject: [PATCH 300/616] refactor(deployment): construct legacy authorization canonically --- semapact/deployment/authorization.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index c58889e2..b670cacb 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -82,10 +82,12 @@ def authorize_deployment( ) return DeploymentAuthorization( deployment_authorization_id=deployment_authorization_id, - contract_ops_authorization_id=authorization.authorization_id, deployment_plan_id=plan.deployment_plan_id, - applied_release_id=source_snapshot_id, + source_snapshot_id=source_snapshot_id, allowed=authorization.allowed, + authorization_kind="contractops", + authorization_reference=authorization.authorization_id, + authorization_version="1", ) deployment_authorization_id = compute_deployment_authorization_id( From 2c0a09fbece5e4768927a83023cdfc9420bc63fc Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:21:34 +1000 Subject: [PATCH 301/616] test(deployment): use explicit canonical model fields --- tests/test_deployment_service.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_deployment_service.py b/tests/test_deployment_service.py index 1af7f49e..e34b8f0b 100644 --- a/tests/test_deployment_service.py +++ b/tests/test_deployment_service.py @@ -104,13 +104,15 @@ def _plan() -> DeploymentPlan: ) return DeploymentPlan( deployment_plan_id=plan_id, - applied_release_id="release-1", + source_snapshot_id="release-1", + release_id="release-1", contract_id="orders-contract", release_plan_id="release-plan-1", - released_revision_ref="abc123", - selected_version="1.2.3", + revision_ref="abc123", + contract_version="1.2.3", target=target, actions=(action,), + plan_version="2", ) @@ -179,10 +181,12 @@ def _authorization(plan: DeploymentPlan) -> DeploymentAuthorization: ) return DeploymentAuthorization( deployment_authorization_id=authorization_id, - contract_ops_authorization_id="contractops-auth-1", deployment_plan_id=plan.deployment_plan_id, - applied_release_id=plan.applied_release_id, + source_snapshot_id=plan.source_snapshot_id, allowed=True, + authorization_kind="contractops", + authorization_reference="contractops-auth-1", + authorization_version="1", ) From 787fc7f4dec93e97cd369ff53fc93f1a4884eb7d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:21:43 +1000 Subject: [PATCH 302/616] test(deployment): exercise explicit legacy plan serialization --- tests/test_deployment_plan.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_deployment_plan.py b/tests/test_deployment_plan.py index 1214151e..d81167f9 100644 --- a/tests/test_deployment_plan.py +++ b/tests/test_deployment_plan.py @@ -15,6 +15,7 @@ compute_applied_release_id, compute_release_snapshot_id, ) +from semapact.deployment.compatibility import serialize_deployment_plan_payload from semapact.deployment import ( DeploymentAction, DeploymentActionKind, @@ -117,7 +118,7 @@ def test_release_snapshot_produces_v3_plan_without_apply_authorization() -> None assert plan.plan_version == "3" assert plan.contract_id == snapshot.contract_id assert plan.selected_version == snapshot.selected_version - payload = plan.model_dump(mode="json") + payload = serialize_deployment_plan_payload(plan) assert payload["release_id"] == snapshot.release_snapshot_id assert "applied_release_id" not in payload @@ -146,7 +147,7 @@ def test_plan_preserves_exact_applied_release_provenance() -> None: assert plan.selected_version == release.selected_version assert plan.plan_version == "2" assert plan.target.source_reference == "https://workspace.example" - payload = plan.model_dump(mode="json") + payload = serialize_deployment_plan_payload(plan) assert payload["applied_release_id"] == release.applied_release_id assert "release_id" not in payload From 89474c058db5564780d3d7269be28c0d87b2ea77 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:21:54 +1000 Subject: [PATCH 303/616] test(deployment): round-trip legacy plan adapters --- tests/test_deployment_plan.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_deployment_plan.py b/tests/test_deployment_plan.py index d81167f9..12c788a8 100644 --- a/tests/test_deployment_plan.py +++ b/tests/test_deployment_plan.py @@ -15,7 +15,10 @@ compute_applied_release_id, compute_release_snapshot_id, ) -from semapact.deployment.compatibility import serialize_deployment_plan_payload +from semapact.deployment.compatibility import ( + parse_deployment_plan_payload, + serialize_deployment_plan_payload, +) from semapact.deployment import ( DeploymentAction, DeploymentActionKind, @@ -121,6 +124,7 @@ def test_release_snapshot_produces_v3_plan_without_apply_authorization() -> None payload = serialize_deployment_plan_payload(plan) assert payload["release_id"] == snapshot.release_snapshot_id assert "applied_release_id" not in payload + assert parse_deployment_plan_payload(payload) == plan def test_same_exact_release_and_target_produce_same_plan() -> None: @@ -150,6 +154,7 @@ def test_plan_preserves_exact_applied_release_provenance() -> None: payload = serialize_deployment_plan_payload(plan) assert payload["applied_release_id"] == release.applied_release_id assert "release_id" not in payload + assert parse_deployment_plan_payload(payload) == plan def test_actions_are_provider_neutral_ensure_state_intents() -> None: From 7f59816da92feb090ea5da2ba51c3a304c04e592 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:22:03 +1000 Subject: [PATCH 304/616] test(deployment): round-trip legacy authorization adapter --- tests/test_deployment_service.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_deployment_service.py b/tests/test_deployment_service.py index e34b8f0b..361d2d27 100644 --- a/tests/test_deployment_service.py +++ b/tests/test_deployment_service.py @@ -19,6 +19,10 @@ NativeOperation, NativeOperationKind, ) +from semapact.deployment.compatibility import ( + parse_deployment_authorization_payload, + serialize_deployment_authorization_payload, +) from semapact.deployment.models import ( compute_deployment_authorization_id, compute_deployment_plan_id, @@ -192,6 +196,18 @@ def _authorization(plan: DeploymentPlan) -> DeploymentAuthorization: +def test_legacy_authorization_wire_format_is_explicitly_adapted() -> None: + plan = _plan() + authorization = _authorization(plan) + + payload = serialize_deployment_authorization_payload(authorization) + + assert payload["contract_ops_authorization_id"] == "contractops-auth-1" + assert payload["applied_release_id"] == plan.source_snapshot_id + assert "source_snapshot_id" not in payload + assert parse_deployment_authorization_payload(payload) == authorization + + def test_preview_delegates_to_unified_adapter_entrypoint() -> None: plan = _plan() adapter = _adapter(plan) From 1d7fae0cf388329bf4f13bf6dc4fd75612d3ea74 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:22:49 +1000 Subject: [PATCH 305/616] refactor(deployment): keep release provenance out of canonical plan --- semapact/deployment/models.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index dc48e44f..88c350e8 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -170,9 +170,9 @@ def _validate_action_order_and_identity(self) -> "DeploymentPlan": raise ValueError("DeploymentPlan cannot contain duplicate governed assets") if self.plan_version == "5": - if self.release_plan_id is not None: + if self.release_id is not None or self.release_plan_id is not None: raise ValueError( - "Canonical DeploymentPlan must not duplicate release_plan_id" + "Canonical DeploymentPlan must not duplicate release provenance" ) else: has_release_id = self.release_id is not None @@ -188,6 +188,10 @@ def _validate_action_order_and_identity(self) -> "DeploymentPlan": @property def is_release(self) -> bool: + if self.plan_version == "5": + raise ValueError( + "Canonical DeploymentPlan release mode belongs to DeploymentSourceSnapshot" + ) return self.release_id is not None @property @@ -392,9 +396,9 @@ def compute_deployment_plan_id( raise ValueError( "Legacy DeploymentPlan release_id and release_plan_id must be provided together" ) - if plan_version == "5" and release_plan_id is not None: + if plan_version == "5" and (release_id is not None or release_plan_id is not None): raise ValueError( - "Canonical DeploymentPlan must not duplicate release_plan_id" + "Canonical DeploymentPlan must not duplicate release provenance" ) derived_release = has_release_id if plan_version == "4" and release is not None and bool(release) != derived_release: From 14cc741af889f38a971e234ae564bfcc1916a8b5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:22:57 +1000 Subject: [PATCH 306/616] refactor(deployment): derive v5 plan only from source identity --- semapact/deployment/planner.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/semapact/deployment/planner.py b/semapact/deployment/planner.py index 9fc86202..6352104a 100644 --- a/semapact/deployment/planner.py +++ b/semapact/deployment/planner.py @@ -101,8 +101,6 @@ def build_deployment_plan_from_source( contract_id=source.contract_id, revision_ref=source.revision_ref, contract_version=source.contract_version, - release_id=source.release_id, - release_plan_id=source.release_plan_id if source.source_kind == "release_snapshot" else None, target=target, actions=ordered_actions, plan_version="5", @@ -115,8 +113,6 @@ def build_deployment_plan_from_source( contract_version=source.contract_version, target=target, actions=ordered_actions, - release_id=source.release_id, - release_plan_id=source.release_plan_id if source.source_kind == "release_snapshot" else None, plan_version="5", ) From cd8b9a97c18144fcc2805b8a94e29e3c05b22ddc Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:23:04 +1000 Subject: [PATCH 307/616] refactor(deployment): keep bundle release mode source-owned --- semapact/application/models/deployment_workflow.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py index 1bbbc810..aab7668f 100644 --- a/semapact/application/models/deployment_workflow.py +++ b/semapact/application/models/deployment_workflow.py @@ -122,12 +122,6 @@ def _validate_bundle_links(self) -> "DeploymentBundle": raise ValueError("DeploymentBundle plan/source revision mismatch") if plan.contract_version != source.contract_version: raise ValueError("DeploymentBundle plan/source version mismatch") - if plan.release_id != source.release_id: - raise ValueError("DeploymentBundle plan/source release provenance mismatch") - if plan.release_plan_id != source.release_plan_id: - raise ValueError( - "DeploymentBundle plan/source release-plan provenance mismatch" - ) if self.review_preview.deployment_plan_id != plan.deployment_plan_id: raise ValueError("DeploymentBundle preview does not match DeploymentPlan") From 74f1c429ab0963d754f02aaf22f55b99a6659e33 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:23:13 +1000 Subject: [PATCH 308/616] refactor(deployment): validate canonical mode from source only --- semapact/deployment/authorization.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index b670cacb..01abc578 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -116,7 +116,7 @@ def validate_candidate_deployment_context( decision: GovernanceDecision, ) -> None: """Validate candidate deployment provenance and fail closed on BLOCK.""" - if source.source_kind != "candidate" or plan.is_release: + if source.source_kind != "candidate": raise ReleaseValidationError( "Candidate deployment requires candidate source without release provenance" ) @@ -147,7 +147,7 @@ def authorize_candidate_deployment( Candidate deployment never creates release approval evidence. BLOCK remains fail-closed; ALLOW and REVIEW may proceed as non-release runtime validation. """ - if plan.is_release or source.source_kind != "candidate": + if source.source_kind != "candidate": raise ReleaseValidationError( "Candidate deployment authorization requires non-release plan/source" ) @@ -190,7 +190,7 @@ def validate_contract_release_deployment_context( release: ContractRelease, ) -> None: """Validate that one plan is derived from the exact finalized release.""" - if source.source_kind != "contract_release" or not plan.is_release: + if source.source_kind != "contract_release": raise ReleaseValidationError( "Finalized release deployment requires ContractRelease source provenance" ) @@ -215,10 +215,6 @@ def validate_contract_release_deployment_context( raise ReleaseValidationError( "Deployment source and ContractRelease revisions do not match" ) - if plan.release_id != release.contract_release_id: - raise ReleaseValidationError( - "DeploymentPlan does not reference the finalized ContractRelease" - ) From 6354e66257a167e4f99a90fcf4462133db40ca9c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:23:21 +1000 Subject: [PATCH 309/616] refactor(databricks): keep release semantics out of provider --- semapact/platforms/databricks/deployment.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/semapact/platforms/databricks/deployment.py b/semapact/platforms/databricks/deployment.py index afe48ff2..fd013dbb 100644 --- a/semapact/platforms/databricks/deployment.py +++ b/semapact/platforms/databricks/deployment.py @@ -158,10 +158,6 @@ def project_release_metadata( metadata: RuntimeReleaseMetadata, ) -> None: """Project SemaPact release provenance into Unity Catalog table tags.""" - if not plan.is_release: - raise ValidationError( - "Release metadata projection requires a formal release DeploymentPlan" - ) if metadata.contract_id != plan.contract_id: raise ValidationError( "Release metadata contract does not match DeploymentPlan" From 7a2c21127e4dab201680d69f5b225f5dac1991ae Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:23:28 +1000 Subject: [PATCH 310/616] test(deployment): keep canonical release provenance on source --- tests/test_deployment_workflow_service.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 43e4e5d4..01b8f873 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -239,7 +239,6 @@ def test_candidate_assessment_does_not_calculate_release_version() -> None: assert bundle.change_set is not None assert bundle.deployment_source.source_kind == "candidate" assert bundle.deployment_source.contract_version == "1.2.3" - assert bundle.deployment_plan.is_release is False assert bundle.deployment_plan.plan_version == "5" @@ -257,7 +256,7 @@ def test_finalized_release_assessment_binds_exact_release_record(tmp_path) -> No assert bundle.contract_release == release assert bundle.deployment_source.release_id == release.contract_release_id assert bundle.deployment_source.contract_version == release.contract_version - assert bundle.deployment_plan.release_id == release.contract_release_id + assert bundle.deployment_plan.release_id is None def test_same_candidate_inputs_produce_same_bundle_digest() -> None: @@ -412,4 +411,4 @@ def test_release_deployment_projects_finalized_version_after_in_sync(tmp_path) - plan, metadata = adapter.metadata_calls[0] assert metadata.contract_version == release.contract_version assert metadata.contract_release_id == release.contract_release_id - assert plan.release_id == release.contract_release_id + assert plan.release_id is None From 7621833cc0c150431308fa351809de1abec0deda Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:24:10 +1000 Subject: [PATCH 311/616] test(databricks): construct legacy artifacts explicitly --- tests/test_deployment_databricks.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_deployment_databricks.py b/tests/test_deployment_databricks.py index 29451dd6..cbd8faca 100644 --- a/tests/test_deployment_databricks.py +++ b/tests/test_deployment_databricks.py @@ -82,13 +82,15 @@ def _plan(*properties: SchemaProperty, source_reference: str = SOURCE_REFERENCE) ) return DeploymentPlan( deployment_plan_id=plan_id, - applied_release_id="applied:test", + source_snapshot_id="applied:test", + release_id="applied:test", contract_id="orders-product", release_plan_id="release-plan:test", - released_revision_ref="rev:released", - selected_version="1.2.0", + revision_ref="rev:released", + contract_version="1.2.0", target=target, actions=(action,), + plan_version="2", ) @@ -101,10 +103,12 @@ def _authorization(plan: DeploymentPlan, allowed: bool = True) -> DeploymentAuth ) return DeploymentAuthorization( deployment_authorization_id=authorization_id, - contract_ops_authorization_id="contractops-auth:test", deployment_plan_id=plan.deployment_plan_id, - applied_release_id=plan.applied_release_id, + source_snapshot_id=plan.source_snapshot_id, allowed=allowed, + authorization_kind="contractops", + authorization_reference="contractops-auth:test", + authorization_version="1", ) From 324bf0b634561bd71d6360a9eb60573be16a5110 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:25:38 +1000 Subject: [PATCH 312/616] test(deployment): update orchestrator legacy fixtures --- tests/test_deployment_orchestrator.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/test_deployment_orchestrator.py b/tests/test_deployment_orchestrator.py index 3992e904..33fc9fdc 100644 --- a/tests/test_deployment_orchestrator.py +++ b/tests/test_deployment_orchestrator.py @@ -145,7 +145,7 @@ def _plan() -> DeploymentPlan: runtime_target="main", source_reference="source-1", ) - kwargs = dict( + identity_kwargs = dict( applied_release_id="release-1", contract_id="contract-1", release_plan_id="release-plan-1", @@ -155,8 +155,16 @@ def _plan() -> DeploymentPlan: actions=(action,), ) return DeploymentPlan( - deployment_plan_id=compute_deployment_plan_id(**kwargs), - **kwargs, + deployment_plan_id=compute_deployment_plan_id(**identity_kwargs), + source_snapshot_id="release-1", + release_id="release-1", + contract_id="contract-1", + release_plan_id="release-plan-1", + revision_ref="revision-1", + contract_version="1.0.0", + target=target, + actions=(action,), + plan_version="2", ) @@ -200,10 +208,12 @@ def test_generic_orchestrator_owns_observe_preview_freshness_and_execute() -> No ) authorization = DeploymentAuthorization( deployment_authorization_id=authorization_id, - contract_ops_authorization_id="contract-auth-1", deployment_plan_id=plan.deployment_plan_id, - applied_release_id=plan.applied_release_id, + source_snapshot_id=plan.source_snapshot_id, allowed=True, + authorization_kind="contractops", + authorization_reference="contract-auth-1", + authorization_version="1", ) orchestrator.execute(plan, preview, authorization) From 0363f754bc3a0fe124d86a199d153a6c1f2fce1b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:25:42 +1000 Subject: [PATCH 313/616] test(deployment): update verification legacy fixture --- tests/test_deployment_verification.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_deployment_verification.py b/tests/test_deployment_verification.py index 4937a896..86c35eee 100644 --- a/tests/test_deployment_verification.py +++ b/tests/test_deployment_verification.py @@ -92,13 +92,15 @@ def _plan() -> DeploymentPlan: ) return DeploymentPlan( deployment_plan_id=plan_id, - applied_release_id="release-1", + source_snapshot_id="release-1", + release_id="release-1", contract_id="orders-contract", release_plan_id="release-plan-1", - released_revision_ref="abc123", - selected_version="1.2.3", + revision_ref="abc123", + contract_version="1.2.3", target=target, actions=(action,), + plan_version="2", ) From cdce99dce0874ce4c6c253a769f9a9d99daa8acf Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:27:15 +1000 Subject: [PATCH 314/616] refactor(history): adapt legacy deployment wire formats at repository boundary --- semapact/platforms/git/history_repository.py | 48 +++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/semapact/platforms/git/history_repository.py b/semapact/platforms/git/history_repository.py index fbaa9582..f7c9c591 100644 --- a/semapact/platforms/git/history_repository.py +++ b/semapact/platforms/git/history_repository.py @@ -7,13 +7,14 @@ from __future__ import annotations import hashlib +import json import os import re import secrets -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar from pydantic import BaseModel, ValidationError as PydanticValidationError @@ -25,6 +26,12 @@ validate_release_plan_identity, ) from semapact.deployment import DeploymentAuthorization, DeploymentPlan, DeploymentPreview +from semapact.deployment.compatibility import ( + parse_deployment_authorization_payload, + parse_deployment_plan_payload, + serialize_deployment_authorization_payload, + serialize_deployment_plan_payload, +) from semapact.deployment.models import ( validate_deployment_authorization_identity, validate_deployment_plan_identity, @@ -73,6 +80,8 @@ class _HistoryKindSpec(Generic[T]): model_type: type[T] id_attribute: str integrity_validator: Callable[[T], None] | None = None + parser: Callable[[Mapping[str, Any]], T] | None = None + serializer: Callable[[T], dict[str, Any]] | None = None _DECISIONS = _HistoryKindSpec( @@ -132,6 +141,8 @@ class _HistoryKindSpec(Generic[T]): DeploymentPlan, "deployment_plan_id", validate_deployment_plan_identity, + parse_deployment_plan_payload, + serialize_deployment_plan_payload, ) _DEPLOYMENT_PREVIEWS = _HistoryKindSpec( "deployment_previews", @@ -144,6 +155,8 @@ class _HistoryKindSpec(Generic[T]): DeploymentAuthorization, "deployment_authorization_id", validate_deployment_authorization_identity, + parse_deployment_authorization_payload, + serialize_deployment_authorization_payload, ) _DEPLOYMENT_RECORDS = _HistoryKindSpec( "deployment_records", @@ -830,7 +843,7 @@ def _require_idempotent_existing( expected_id: str, ) -> None: existing = self._read_validated(spec, path, expected_id=expected_id) - existing_canonical = _canonical_model_json(existing) + existing_canonical = _canonical_artifact_json(spec, existing) if existing_canonical != canonical: raise HistoryConflictError( f"{spec.model_type.__name__} {expected_id!r} already exists with different content" @@ -847,7 +860,7 @@ def _read_validated( try: raw = path.read_text(encoding="utf-8") self._verify_checksum_if_present(path, raw) - artifact = spec.model_type.model_validate_json(raw) + artifact = _parse_artifact_json(spec, raw) if spec.integrity_validator is not None: spec.integrity_validator(artifact) except HistoryCorruptionError: @@ -876,8 +889,8 @@ def _validated_canonical_json( f"artifact must be {spec.model_type.__name__}, got {type(artifact).__name__}" ) try: - canonical = _canonical_model_json(artifact) - validated = spec.model_type.model_validate_json(canonical) + canonical = _canonical_artifact_json(spec, artifact) + validated = _parse_artifact_json(spec, canonical) if spec.integrity_validator is not None: spec.integrity_validator(validated) except (PydanticValidationError, ValueError, TypeError) as exc: @@ -1124,6 +1137,29 @@ def _path_name(path: Path) -> str: return path.name +def _canonical_artifact_json( + spec: _HistoryKindSpec[T], + artifact: T, +) -> str: + """Serialize at the persistence boundary, including legacy wire compatibility.""" + if spec.serializer is not None: + return canonical_compact_json(spec.serializer(artifact)) + return _canonical_model_json(artifact) + + +def _parse_artifact_json( + spec: _HistoryKindSpec[T], + raw: str, +) -> T: + """Rehydrate persisted wire data through the kind-specific boundary adapter.""" + if spec.parser is not None: + payload = json.loads(raw) + if not isinstance(payload, dict): + raise ValueError("Persisted history artifact must be a JSON object") + return spec.parser(payload) + return spec.model_type.model_validate_json(raw) + + def _canonical_model_json(artifact: BaseModel) -> str: """Serialize persisted models with aliases so nested ODCS models round-trip.""" return canonical_compact_json(artifact.model_dump(mode="json", by_alias=True)) From bd92cf11c0a830317fed9ff94f9f1ad56df59407 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:27:25 +1000 Subject: [PATCH 315/616] test(history): construct legacy deployment artifacts explicitly --- tests/test_deployment_history.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_deployment_history.py b/tests/test_deployment_history.py index 0279e699..2b008e55 100644 --- a/tests/test_deployment_history.py +++ b/tests/test_deployment_history.py @@ -71,13 +71,15 @@ def _deployment_artifacts(*, preview_runtime_target: str = "catalog.schema", all ) plan = DeploymentPlan( deployment_plan_id=plan_id, - applied_release_id="applied-release-1", + source_snapshot_id="applied-release-1", + release_id="applied-release-1", contract_id="orders-product", release_plan_id="release-plan-1", - released_revision_ref="candidate-revision-1", - selected_version="1.3.0", + revision_ref="candidate-revision-1", + contract_version="1.3.0", target=target, actions=(), + plan_version="2", ) preview_id = compute_deployment_preview_id( @@ -106,10 +108,12 @@ def _deployment_artifacts(*, preview_runtime_target: str = "catalog.schema", all ) authorization = DeploymentAuthorization( deployment_authorization_id=authorization_id, - contract_ops_authorization_id="deploy-authorization-1", deployment_plan_id=plan.deployment_plan_id, - applied_release_id=plan.applied_release_id, + source_snapshot_id=plan.source_snapshot_id, allowed=allowed, + authorization_kind="contractops", + authorization_reference="deploy-authorization-1", + authorization_version="1", ) return plan, preview, authorization From 77e81cc5cfee076bd3b39ae6ff8b3826011fb899 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:27:58 +1000 Subject: [PATCH 316/616] fix(history): use compatibility parser during integrity inspection --- semapact/platforms/git/history_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/platforms/git/history_repository.py b/semapact/platforms/git/history_repository.py index f7c9c591..50ddaa20 100644 --- a/semapact/platforms/git/history_repository.py +++ b/semapact/platforms/git/history_repository.py @@ -657,7 +657,7 @@ def _inspect_history_artifact( ) try: - artifact = spec.model_type.model_validate_json(raw) + artifact = _parse_artifact_json(spec, raw) if spec.integrity_validator is not None: spec.integrity_validator(artifact) except (PydanticValidationError, ValueError, TypeError) as exc: From f256191930ba89d0f6b3ea2f197702f8e74b71a7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:59:15 +1000 Subject: [PATCH 317/616] refactor(deployment): make deployment models canonical only --- semapact/deployment/models.py | 278 ++++------------------------------ 1 file changed, 26 insertions(+), 252 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index 88c350e8..2c78d9d1 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -125,7 +125,7 @@ def _validate_desired_state_identity(self) -> DeploymentAction: class DeploymentPlan(DeploymentModel): - """Pure deterministic runtime convergence plan for one exact source snapshot.""" + """Canonical deterministic runtime convergence plan for one exact source snapshot.""" deployment_plan_id: str source_snapshot_id: str @@ -134,10 +134,7 @@ class DeploymentPlan(DeploymentModel): contract_version: str target: DeploymentTarget actions: tuple[DeploymentAction, ...] - release_id: str | None = None - release_plan_id: str | None = None - plan_version: Literal["2", "3", "4", "5"] = "5" - + plan_version: Literal["5"] = "5" @field_validator( "deployment_plan_id", @@ -153,14 +150,6 @@ def _require_plan_text(cls, value: str) -> str: raise ValueError("value must not be empty") return cleaned - @field_validator("release_id", "release_plan_id") - @classmethod - def _normalize_optional_plan_text(cls, value: str | None) -> str | None: - if value is None: - return None - cleaned = value.strip() - return cleaned or None - @model_validator(mode="after") def _validate_action_order_and_identity(self) -> "DeploymentPlan": governed_assets = [action.governed_asset for action in self.actions] @@ -169,54 +158,10 @@ def _validate_action_order_and_identity(self) -> "DeploymentPlan": if len(governed_assets) != len(set(governed_assets)): raise ValueError("DeploymentPlan cannot contain duplicate governed assets") - if self.plan_version == "5": - if self.release_id is not None or self.release_plan_id is not None: - raise ValueError( - "Canonical DeploymentPlan must not duplicate release provenance" - ) - else: - has_release_id = self.release_id is not None - has_release_plan = self.release_plan_id is not None - if has_release_id != has_release_plan: - raise ValueError( - "Legacy DeploymentPlan release_id and release_plan_id must be provided together" - ) - validate_deployment_plan_identity(self) return self - @property - def is_release(self) -> bool: - if self.plan_version == "5": - raise ValueError( - "Canonical DeploymentPlan release mode belongs to DeploymentSourceSnapshot" - ) - return self.release_id is not None - - @property - def release(self) -> bool: - """Compatibility accessor; canonical state is release provenance.""" - return self.is_release - - @property - def applied_release_id(self) -> str: - """Compatibility alias for callers using the v2 release field.""" - if self.release_id is None: - raise ValueError("Non-release DeploymentPlan has no applied release") - return self.release_id - - @property - def released_revision_ref(self) -> str: - """Compatibility alias for pre-v4 callers.""" - return self.revision_ref - - @property - def selected_version(self) -> str: - """Compatibility alias for pre-v4 callers.""" - return self.contract_version - - class NativeOperation(DeploymentModel): """One immutable provider-native operation selected by an adapter preview.""" @@ -277,7 +222,7 @@ def _validate_identity(self) -> DeploymentPreview: class DeploymentAuthorization(DeploymentModel): - """Authorization bound to one exact DeploymentPlan source snapshot.""" + """Canonical authorization bound to one exact DeploymentPlan source snapshot.""" deployment_authorization_id: str deployment_plan_id: str @@ -285,8 +230,7 @@ class DeploymentAuthorization(DeploymentModel): allowed: bool = Field(strict=True) authorization_kind: Literal["contractops", "governance"] = "contractops" authorization_reference: str - authorization_version: Literal["1", "2"] = "2" - + authorization_version: Literal["2"] = "2" @field_validator( "deployment_authorization_id", @@ -307,187 +251,54 @@ def _validate_identity(self) -> "DeploymentAuthorization": return self - @property - def contract_ops_authorization_id(self) -> str: - """Compatibility accessor for ContractOps-backed authorization.""" - if self.authorization_kind != "contractops": - raise ValueError("Authorization is not backed by ContractOps") - return self.authorization_reference - - @property - def applied_release_id(self) -> str: - """Compatibility accessor for pre-v2 deployment authorization.""" - return self.source_snapshot_id - - def compute_deployment_plan_id( *, - source_snapshot_id: str | None = None, - release_id: str | None = None, - applied_release_id: str | None = None, + source_snapshot_id: str, contract_id: str, - revision_ref: str | None = None, - released_revision_ref: str | None = None, - contract_version: str | None = None, - selected_version: str | None = None, - release_plan_id: str | None = None, - release: bool | None = None, + revision_ref: str, + contract_version: str, target: DeploymentTarget, actions: Sequence[DeploymentAction], - plan_version: str | None = None, + plan_version: str = "5", ) -> str: - if plan_version is None: - if applied_release_id is not None: - plan_version = "2" - elif source_snapshot_id is not None: - plan_version = "5" - else: - plan_version = "3" - - legacy_release_id = _resolve_optional_legacy_release_id( - release_id=release_id, - applied_release_id=applied_release_id, - ) - resolved_source_id = ( - source_snapshot_id.strip() - if isinstance(source_snapshot_id, str) and source_snapshot_id.strip() - else legacy_release_id - ) - if resolved_source_id is None: - raise ValueError("source_snapshot_id is required") - - resolved_revision = _resolve_text_pair( - revision_ref, - released_revision_ref, - "revision_ref", - ) - resolved_version = _resolve_text_pair( - contract_version, - selected_version, - "contract_version", - ) - - if plan_version in {"2", "3"}: - if legacy_release_id is None: - legacy_release_id = resolved_source_id - if release_plan_id is None: - raise ValueError("legacy DeploymentPlan requires release_plan_id") - release_key = "applied_release_id" if plan_version == "2" else "release_id" - return deterministic_uuid5( - SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, - { - release_key: legacy_release_id, - "contract_id": contract_id, - "release_plan_id": release_plan_id, - "released_revision_ref": resolved_revision, - "selected_version": resolved_version, - "target": target.model_dump(mode="json"), - "actions": [action.model_dump(mode="json") for action in actions], - "plan_version": plan_version, - }, - ) - - if plan_version not in {"4", "5"}: - raise ValueError(f"Unsupported DeploymentPlan version: {plan_version}") - - has_release_id = release_id is not None - has_release_plan = release_plan_id is not None - if plan_version == "4" and has_release_id != has_release_plan: - raise ValueError( - "Legacy DeploymentPlan release_id and release_plan_id must be provided together" - ) - if plan_version == "5" and (release_id is not None or release_plan_id is not None): - raise ValueError( - "Canonical DeploymentPlan must not duplicate release provenance" - ) - derived_release = has_release_id - if plan_version == "4" and release is not None and bool(release) != derived_release: - raise ValueError( - "Legacy DeploymentPlan release flag conflicts with release provenance" - ) - - payload = { - "source_snapshot_id": resolved_source_id, - "contract_id": contract_id, - "revision_ref": resolved_revision, - "contract_version": resolved_version, - "release_id": release_id, - "release_plan_id": release_plan_id if plan_version == "4" else None, - "target": target.model_dump(mode="json"), - "actions": [action.model_dump(mode="json") for action in actions], - "plan_version": plan_version, - } - if plan_version == "4": - payload["release"] = derived_release + if plan_version != "5": + raise ValueError(f"Unsupported canonical DeploymentPlan version: {plan_version}") return deterministic_uuid5( SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, - payload, + { + "source_snapshot_id": source_snapshot_id, + "contract_id": contract_id, + "revision_ref": revision_ref, + "contract_version": contract_version, + "target": target.model_dump(mode="json"), + "actions": [action.model_dump(mode="json") for action in actions], + "plan_version": plan_version, + }, ) def compute_deployment_authorization_id( *, deployment_plan_id: str, - source_snapshot_id: str | None = None, - authorization_kind: str = "contractops", - authorization_reference: str | None = None, + source_snapshot_id: str, + authorization_kind: str, + authorization_reference: str, allowed: bool, - contract_ops_authorization_id: str | None = None, - applied_release_id: str | None = None, - authorization_version: str | None = None, + authorization_version: str = "2", ) -> str: - if authorization_version is None: - authorization_version = ( - "1" - if contract_ops_authorization_id is not None - or applied_release_id is not None - else "2" - ) - - if authorization_version == "1": - contractops_id = _resolve_text_pair( - authorization_reference, - contract_ops_authorization_id, - "contract_ops_authorization_id", - ) - release_id = _resolve_text_pair( - source_snapshot_id, - applied_release_id, - "applied_release_id", - ) - return deterministic_uuid5( - SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, - { - "contract_ops_authorization_id": contractops_id, - "deployment_plan_id": deployment_plan_id, - "applied_release_id": release_id, - "allowed": allowed, - }, - ) - if authorization_version != "2": raise ValueError( - f"Unsupported DeploymentAuthorization version: {authorization_version}" + f"Unsupported canonical DeploymentAuthorization version: {authorization_version}" ) if authorization_kind not in {"contractops", "governance"}: raise ValueError("Unsupported deployment authorization kind") - source_id = _resolve_text_pair( - source_snapshot_id, - None, - "source_snapshot_id", - ) - reference = _resolve_text_pair( - authorization_reference, - None, - "authorization_reference", - ) return deterministic_uuid5( SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, { "authorization_kind": authorization_kind, - "authorization_reference": reference, + "authorization_reference": authorization_reference, "deployment_plan_id": deployment_plan_id, - "source_snapshot_id": source_id, + "source_snapshot_id": source_snapshot_id, "allowed": allowed, "authorization_version": authorization_version, }, @@ -518,49 +329,12 @@ def compute_deployment_preview_id( ) -def _resolve_optional_legacy_release_id( - *, - release_id: str | None, - applied_release_id: str | None, -) -> str | None: - values = { - value.strip() - for value in (release_id, applied_release_id) - if isinstance(value, str) and value.strip() - } - if len(values) > 1: - raise ValueError( - "release_id and applied_release_id must identify the same release" - ) - return values.pop() if values else None - - -def _resolve_text_pair( - primary: str | None, - compatibility: str | None, - field_name: str, -) -> str: - values = { - value.strip() - for value in (primary, compatibility) - if isinstance(value, str) and value.strip() - } - if not values: - raise ValueError(f"{field_name} is required") - if len(values) != 1: - raise ValueError(f"{field_name} compatibility values do not match") - return values.pop() - - def validate_deployment_plan_identity(plan: DeploymentPlan) -> None: expected = compute_deployment_plan_id( source_snapshot_id=plan.source_snapshot_id, - release_id=plan.release_id, contract_id=plan.contract_id, - release_plan_id=plan.release_plan_id, revision_ref=plan.revision_ref, contract_version=plan.contract_version, - release=plan.is_release if plan.plan_version == "4" else None, target=plan.target, actions=plan.actions, plan_version=plan.plan_version, From a4243e2839cf2e9f18e7a6bd62751a3c597a8ac5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 09:59:34 +1000 Subject: [PATCH 318/616] refactor(deployment): canonicalize deployment source model --- semapact/deployment/source.py | 66 ++++------------------------------- 1 file changed, 7 insertions(+), 59 deletions(-) diff --git a/semapact/deployment/source.py b/semapact/deployment/source.py index 954bea7a..3c60ba17 100644 --- a/semapact/deployment/source.py +++ b/semapact/deployment/source.py @@ -8,7 +8,7 @@ from open_data_contract_standard.model import OpenDataContractStandard from pydantic import BaseModel, ConfigDict, field_validator, model_validator -from semapact.contractops import ContractRelease, ReleaseSnapshot +from semapact.contractops import ContractRelease from semapact.utils.deterministic import canonical_compact_json, deterministic_uuid5 @@ -16,11 +16,7 @@ "598e0c13-d9af-4cf9-a8a7-9fc7628e17c1" ) -DeploymentSourceKind = Literal[ - "candidate", - "release_snapshot", - "contract_release", -] +DeploymentSourceKind = Literal["candidate", "contract_release"] class DeploymentSourceSnapshot(BaseModel): @@ -40,7 +36,6 @@ class DeploymentSourceSnapshot(BaseModel): contract_version: str contract_json: str release_id: str | None = None - release_plan_id: str | None = None @field_validator( "source_snapshot_id", @@ -65,24 +60,14 @@ def _validate_source(self) -> "DeploymentSourceSnapshot": raise ValueError("DeploymentSourceSnapshot contract version mismatch") if self.source_kind == "candidate": - if self.release_id is not None or self.release_plan_id is not None: + if self.release_id is not None: raise ValueError( "Candidate deployment source cannot contain release provenance" ) - elif self.source_kind == "release_snapshot": - if self.release_id is None or self.release_plan_id is None: - raise ValueError( - "Legacy ReleaseSnapshot source requires release provenance" - ) - else: - if self.release_id is None: - raise ValueError( - "ContractRelease source requires contract_release_id provenance" - ) - if self.release_plan_id is not None: - raise ValueError( - "Canonical ContractRelease source must not duplicate release_plan_id" - ) + elif self.release_id is None: + raise ValueError( + "ContractRelease source requires contract_release_id provenance" + ) expected = compute_deployment_source_id( source_kind=self.source_kind, @@ -91,7 +76,6 @@ def _validate_source(self) -> "DeploymentSourceSnapshot": contract_version=self.contract_version, contract_json=self.contract_json, release_id=self.release_id, - release_plan_id=self.release_plan_id, ) if self.source_snapshot_id != expected: raise ValueError( @@ -103,11 +87,6 @@ def _validate_source(self) -> "DeploymentSourceSnapshot": def is_release(self) -> bool: return self.source_kind != "candidate" - @property - def release(self) -> bool: - """Compatibility accessor; canonical state lives in source_kind.""" - return self.is_release - def to_contract(self) -> OpenDataContractStandard: return OpenDataContractStandard.model_validate_json(self.contract_json) @@ -140,7 +119,6 @@ def build_candidate_deployment_source( contract_version=contract_version, contract_json=contract_json, release_id=None, - release_plan_id=None, ) return DeploymentSourceSnapshot( source_snapshot_id=source_id, @@ -152,32 +130,6 @@ def build_candidate_deployment_source( ) -def build_release_deployment_source( - snapshot: ReleaseSnapshot, -) -> DeploymentSourceSnapshot: - """Compatibility projection from a pre-finalization ReleaseSnapshot.""" - contract_json = snapshot.released_contract_json - source_id = compute_deployment_source_id( - source_kind="release_snapshot", - contract_id=snapshot.contract_id, - revision_ref=snapshot.release_revision_ref, - contract_version=snapshot.selected_version, - contract_json=contract_json, - release_id=snapshot.release_snapshot_id, - release_plan_id=snapshot.release_plan_id, - ) - return DeploymentSourceSnapshot( - source_snapshot_id=source_id, - source_kind="release_snapshot", - contract_id=snapshot.contract_id, - revision_ref=snapshot.release_revision_ref, - contract_version=snapshot.selected_version, - contract_json=contract_json, - release_id=snapshot.release_snapshot_id, - release_plan_id=snapshot.release_plan_id, - ) - - def build_contract_release_deployment_source( release: ContractRelease, ) -> DeploymentSourceSnapshot: @@ -195,7 +147,6 @@ def build_contract_release_deployment_source( contract_version=release.contract_version, contract_json=contract_json, release_id=release.contract_release_id, - release_plan_id=None, ) return DeploymentSourceSnapshot( source_snapshot_id=source_id, @@ -205,7 +156,6 @@ def build_contract_release_deployment_source( contract_version=release.contract_version, contract_json=contract_json, release_id=release.contract_release_id, - release_plan_id=None, ) @@ -217,7 +167,6 @@ def compute_deployment_source_id( contract_version: str, contract_json: str, release_id: str | None, - release_plan_id: str | None, ) -> str: return deterministic_uuid5( SEMAPACT_DEPLOYMENT_SOURCE_NAMESPACE, @@ -228,6 +177,5 @@ def compute_deployment_source_id( "contract_version": contract_version, "contract_json": contract_json, "release_id": release_id, - "release_plan_id": release_plan_id, }, ) From d13b62bef4adc623430ec8000d387aad0be0ad8b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:00:01 +1000 Subject: [PATCH 319/616] refactor(deployment): upgrade legacy artifacts at compatibility boundary --- semapact/deployment/compatibility.py | 377 +++++++++++++++++++++------ 1 file changed, 299 insertions(+), 78 deletions(-) diff --git a/semapact/deployment/compatibility.py b/semapact/deployment/compatibility.py index f6ca24cd..8619eca9 100644 --- a/semapact/deployment/compatibility.py +++ b/semapact/deployment/compatibility.py @@ -1,8 +1,8 @@ -"""Explicit compatibility adapters for legacy deployment artifacts. +"""Boundary adapters for legacy deployment artifacts. -Canonical deployment models validate canonical field names only. Legacy JSON is -upgraded or serialized here at the boundary so domain models do not hide migration -behavior in Pydantic validators/serializers. +Legacy wire formats are verified with their historical deterministic identities and +then upgraded into canonical DeploymentPlan/DeploymentAuthorization objects. The +canonical domain models never persist legacy fields or versions. """ from __future__ import annotations @@ -10,100 +10,321 @@ from collections.abc import Mapping from typing import Any -from semapact.deployment.models import DeploymentAuthorization, DeploymentPlan +from semapact.contractops.execution_models import AppliedContractRelease, ReleaseSnapshot +from semapact.contractops.integrity import ( + validate_applied_release_identity, + validate_release_snapshot_identity, +) +from semapact.deployment.models import ( + SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, + SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, + DeploymentAction, + DeploymentAuthorization, + DeploymentPlan, + DeploymentTarget, + compute_deployment_authorization_id, + compute_deployment_plan_id, +) +from semapact.deployment.planner import build_deployment_actions +from semapact.utils.deterministic import deterministic_uuid5 def parse_deployment_plan_payload(payload: Mapping[str, Any]) -> DeploymentPlan: - """Adapt a legacy or canonical plan payload into DeploymentPlan.""" + """Verify and upgrade a legacy plan payload into canonical v5.""" value = dict(payload) - raw_version = value.get("plan_version") - if raw_version is None: - if "applied_release_id" in value: - version = "2" - elif ( - "release_id" in value - or "released_revision_ref" in value - or "selected_version" in value - ): - version = "3" - else: - return DeploymentPlan.model_validate(value) - value["plan_version"] = version - else: - version = str(raw_version) + version = _infer_plan_version(value) - if version == "4": - legacy_release = value.pop("release", None) - has_release_provenance = bool( - value.get("release_id") or value.get("release_plan_id") - ) - if legacy_release is not None and bool(legacy_release) != has_release_provenance: - raise ValueError( - "Legacy DeploymentPlan release flag conflicts with release provenance" - ) + if version == "5" and not _has_legacy_plan_fields(value): return DeploymentPlan.model_validate(value) - if version in {"2", "3"}: - legacy_release_id = value.get("release_id") or value.get( - "applied_release_id" - ) - value.setdefault("source_snapshot_id", legacy_release_id) - value.setdefault("revision_ref", value.get("released_revision_ref")) - value.setdefault("contract_version", value.get("selected_version")) - value.setdefault("release_id", legacy_release_id) - value.pop("applied_release_id", None) - value.pop("released_revision_ref", None) - value.pop("selected_version", None) + target = DeploymentTarget.model_validate(value.get("target")) + actions = tuple( + DeploymentAction.model_validate(action) + for action in value.get("actions", ()) + ) + source_snapshot_id = _legacy_plan_source_id(value, version) + revision_ref = _legacy_plan_revision_ref(value) + contract_version = _legacy_plan_contract_version(value) + contract_id = _required_text(value.get("contract_id"), "contract_id") - return DeploymentPlan.model_validate(value) + expected_legacy_id = _compute_legacy_deployment_plan_id( + version=version, + source_snapshot_id=source_snapshot_id, + contract_id=contract_id, + revision_ref=revision_ref, + contract_version=contract_version, + release_id=_optional_text(value.get("release_id")) + or (_optional_text(value.get("applied_release_id")) if version == "2" else None), + release_plan_id=_optional_text(value.get("release_plan_id")), + release=value.get("release"), + target=target, + actions=actions, + ) + if value.get("deployment_plan_id") != expected_legacy_id: + raise ValueError("Legacy DeploymentPlan deterministic identity does not match content") + + canonical_id = compute_deployment_plan_id( + source_snapshot_id=source_snapshot_id, + contract_id=contract_id, + revision_ref=revision_ref, + contract_version=contract_version, + target=target, + actions=actions, + ) + return DeploymentPlan( + deployment_plan_id=canonical_id, + source_snapshot_id=source_snapshot_id, + contract_id=contract_id, + revision_ref=revision_ref, + contract_version=contract_version, + target=target, + actions=actions, + ) def serialize_deployment_plan_payload(plan: DeploymentPlan) -> dict[str, Any]: - """Serialize DeploymentPlan using its historical wire format when required.""" - payload = plan.model_dump(mode="json") - if plan.plan_version == "4": - payload["release"] = plan.is_release - return payload - if plan.plan_version not in {"2", "3"}: - return payload - - payload["released_revision_ref"] = payload.pop("revision_ref") - payload["selected_version"] = payload.pop("contract_version") - payload.pop("source_snapshot_id", None) - if plan.plan_version == "2": - payload["applied_release_id"] = payload.pop("release_id") - return payload + """Persist only the canonical DeploymentPlan wire shape.""" + return plan.model_dump(mode="json") def parse_deployment_authorization_payload( payload: Mapping[str, Any], ) -> DeploymentAuthorization: - """Adapt a legacy or canonical authorization payload.""" + """Verify and upgrade a legacy authorization payload into canonical v2.""" value = dict(payload) - if "source_snapshot_id" not in value: - contractops_id = value.get("contract_ops_authorization_id") - release_id = value.get("applied_release_id") - if contractops_id is not None and release_id is not None: - value["source_snapshot_id"] = release_id - value["authorization_kind"] = "contractops" - value["authorization_reference"] = contractops_id - value["authorization_version"] = "1" - value.pop("contract_ops_authorization_id", None) - value.pop("applied_release_id", None) - return DeploymentAuthorization.model_validate(value) + if ( + "contract_ops_authorization_id" not in value + and "applied_release_id" not in value + and str(value.get("authorization_version", "2")) == "2" + ): + return DeploymentAuthorization.model_validate(value) + + deployment_plan_id = _required_text( + value.get("deployment_plan_id"), + "deployment_plan_id", + ) + source_snapshot_id = _required_text( + value.get("source_snapshot_id") or value.get("applied_release_id"), + "source_snapshot_id", + ) + authorization_reference = _required_text( + value.get("authorization_reference") + or value.get("contract_ops_authorization_id"), + "authorization_reference", + ) + authorization_kind = str(value.get("authorization_kind") or "contractops") + allowed = value.get("allowed") + if not isinstance(allowed, bool): + raise ValueError("allowed must be boolean") + + expected_legacy_id = deterministic_uuid5( + SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, + { + "contract_ops_authorization_id": authorization_reference, + "deployment_plan_id": deployment_plan_id, + "applied_release_id": source_snapshot_id, + "allowed": allowed, + }, + ) + if value.get("deployment_authorization_id") != expected_legacy_id: + raise ValueError( + "Legacy DeploymentAuthorization deterministic identity does not match content" + ) + + canonical_id = compute_deployment_authorization_id( + deployment_plan_id=deployment_plan_id, + source_snapshot_id=source_snapshot_id, + authorization_kind=authorization_kind, + authorization_reference=authorization_reference, + allowed=allowed, + ) + return DeploymentAuthorization( + deployment_authorization_id=canonical_id, + deployment_plan_id=deployment_plan_id, + source_snapshot_id=source_snapshot_id, + allowed=allowed, + authorization_kind=authorization_kind, + authorization_reference=authorization_reference, + ) def serialize_deployment_authorization_payload( authorization: DeploymentAuthorization, ) -> dict[str, Any]: - """Serialize authorization using the v1 wire shape only when requested.""" - payload = authorization.model_dump(mode="json") - if authorization.authorization_version != "1": - return payload - payload["contract_ops_authorization_id"] = payload.pop( - "authorization_reference" + """Persist only the canonical DeploymentAuthorization wire shape.""" + return authorization.model_dump(mode="json") + + +def build_legacy_deployment_plan( + release: ReleaseSnapshot | AppliedContractRelease, + target: DeploymentTarget, +) -> DeploymentPlan: + """Adapt pre-ContractRelease release artifacts into one canonical plan. + + This is an explicit compatibility entrypoint. New workflows must plan from a + DeploymentSourceSnapshot instead. + """ + if isinstance(release, ReleaseSnapshot): + validate_release_snapshot_identity(release) + source_snapshot_id = release.release_snapshot_id + elif isinstance(release, AppliedContractRelease): + validate_applied_release_identity(release) + source_snapshot_id = release.applied_release_id + else: + raise TypeError( + "release must be ReleaseSnapshot or AppliedContractRelease, " + f"got {type(release).__name__}" + ) + if not isinstance(target, DeploymentTarget): + raise TypeError( + f"target must be DeploymentTarget, got {type(target).__name__}" + ) + + actions = build_deployment_actions(release.to_contract()) + plan_id = compute_deployment_plan_id( + source_snapshot_id=source_snapshot_id, + contract_id=release.contract_id, + revision_ref=release.release_revision_ref, + contract_version=release.selected_version, + target=target, + actions=actions, + ) + return DeploymentPlan( + deployment_plan_id=plan_id, + source_snapshot_id=source_snapshot_id, + contract_id=release.contract_id, + revision_ref=release.release_revision_ref, + contract_version=release.selected_version, + target=target, + actions=actions, ) - payload["applied_release_id"] = payload.pop("source_snapshot_id") - payload.pop("authorization_kind", None) - payload.pop("authorization_version", None) - return payload + + +def _infer_plan_version(value: Mapping[str, Any]) -> str: + raw = value.get("plan_version") + if raw is not None: + version = str(raw) + elif "applied_release_id" in value: + version = "2" + elif ( + "release_id" in value + or "released_revision_ref" in value + or "selected_version" in value + ): + version = "3" + else: + version = "5" + if version not in {"2", "3", "4", "5"}: + raise ValueError(f"Unsupported legacy DeploymentPlan version: {version}") + return version + + +def _has_legacy_plan_fields(value: Mapping[str, Any]) -> bool: + return any( + field in value + for field in ( + "applied_release_id", + "release_id", + "release_plan_id", + "released_revision_ref", + "selected_version", + "release", + ) + ) + + +def _legacy_plan_source_id(value: Mapping[str, Any], version: str) -> str: + if version == "2": + raw = value.get("applied_release_id") or value.get("source_snapshot_id") + elif version == "3": + raw = value.get("release_id") or value.get("source_snapshot_id") + else: + raw = value.get("source_snapshot_id") + return _required_text(raw, "source_snapshot_id") + + +def _legacy_plan_revision_ref(value: Mapping[str, Any]) -> str: + return _required_text( + value.get("revision_ref") or value.get("released_revision_ref"), + "revision_ref", + ) + + +def _legacy_plan_contract_version(value: Mapping[str, Any]) -> str: + return _required_text( + value.get("contract_version") or value.get("selected_version"), + "contract_version", + ) + + +def _compute_legacy_deployment_plan_id( + *, + version: str, + source_snapshot_id: str, + contract_id: str, + revision_ref: str, + contract_version: str, + release_id: str | None, + release_plan_id: str | None, + release: object, + target: DeploymentTarget, + actions: tuple[DeploymentAction, ...], +) -> str: + if version in {"2", "3"}: + if release_id is None or release_plan_id is None: + raise ValueError( + f"Legacy DeploymentPlan v{version} requires release provenance" + ) + release_key = "applied_release_id" if version == "2" else "release_id" + return deterministic_uuid5( + SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, + { + release_key: release_id, + "contract_id": contract_id, + "release_plan_id": release_plan_id, + "released_revision_ref": revision_ref, + "selected_version": contract_version, + "target": target.model_dump(mode="json"), + "actions": [action.model_dump(mode="json") for action in actions], + "plan_version": version, + }, + ) + + has_release_id = release_id is not None + has_release_plan = release_plan_id is not None + if version == "4" and has_release_id != has_release_plan: + raise ValueError( + "Legacy DeploymentPlan release_id and release_plan_id must be provided together" + ) + if version == "4" and release is not None and bool(release) != has_release_id: + raise ValueError( + "Legacy DeploymentPlan release flag conflicts with release provenance" + ) + + legacy_payload = { + "source_snapshot_id": source_snapshot_id, + "contract_id": contract_id, + "revision_ref": revision_ref, + "contract_version": contract_version, + "release_id": release_id, + "release_plan_id": release_plan_id if version == "4" else None, + "target": target.model_dump(mode="json"), + "actions": [action.model_dump(mode="json") for action in actions], + "plan_version": version, + } + if version == "4": + legacy_payload["release"] = has_release_id + return deterministic_uuid5(SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, legacy_payload) + + +def _required_text(value: object, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} is required") + return value.strip() + + +def _optional_text(value: object) -> str | None: + if not isinstance(value, str): + return None + cleaned = value.strip() + return cleaned or None From 746fc00a6c52905a7e63b4a20959729158db03e4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:00:11 +1000 Subject: [PATCH 320/616] refactor(deployment): keep canonical planner source-based --- semapact/deployment/planner.py | 64 +--------------------------------- 1 file changed, 1 insertion(+), 63 deletions(-) diff --git a/semapact/deployment/planner.py b/semapact/deployment/planner.py index 6352104a..7ef0cd8e 100644 --- a/semapact/deployment/planner.py +++ b/semapact/deployment/planner.py @@ -1,14 +1,9 @@ -"""Pure compilation of applied contract releases into deployment convergence plans.""" +"""Pure compilation of canonical deployment sources into convergence plans.""" from __future__ import annotations from open_data_contract_standard.model import OpenDataContractStandard, SchemaObject -from semapact.contractops.execution_models import AppliedContractRelease, ReleaseSnapshot -from semapact.contractops.integrity import ( - validate_applied_release_identity, - validate_release_snapshot_identity, -) from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, @@ -22,63 +17,6 @@ from semapact.utils.deterministic import canonical_compact_json -def build_deployment_plan( - release: ReleaseSnapshot | AppliedContractRelease, - target: DeploymentTarget, -) -> DeploymentPlan: - """Build one deterministic provider-neutral convergence plan. - - Planning consumes exact released desired state only. It does not observe runtime, - choose CREATE/ALTER/DROP operations, contact a provider, or recompute governance. - """ - if not isinstance(release, (ReleaseSnapshot, AppliedContractRelease)): - raise TypeError( - "release must be ReleaseSnapshot or AppliedContractRelease, " - f"got {type(release).__name__}" - ) - if not isinstance(target, DeploymentTarget): - raise TypeError( - f"target must be DeploymentTarget, got {type(target).__name__}" - ) - - if isinstance(release, ReleaseSnapshot): - validate_release_snapshot_identity(release) - release_id = release.release_snapshot_id - plan_version = "3" - else: - validate_applied_release_identity(release) - release_id = release.applied_release_id - plan_version = "2" - - contract = release.to_contract() - ordered_actions = build_deployment_actions(contract) - deployment_plan_id = compute_deployment_plan_id( - release_id=release_id, - contract_id=release.contract_id, - release_plan_id=release.release_plan_id, - released_revision_ref=release.release_revision_ref, - selected_version=release.selected_version, - target=target, - actions=ordered_actions, - plan_version=plan_version, - ) - - return DeploymentPlan( - deployment_plan_id=deployment_plan_id, - source_snapshot_id=release_id, - release_id=release_id, - contract_id=release.contract_id, - release_plan_id=release.release_plan_id, - revision_ref=release.release_revision_ref, - contract_version=release.selected_version, - target=target, - actions=ordered_actions, - plan_version=plan_version, - ) - - - - def build_deployment_plan_from_source( source: DeploymentSourceSnapshot, target: DeploymentTarget, From bd7f55200cab19d6bc252f24cba07b5301136dc0 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:00:14 +1000 Subject: [PATCH 321/616] refactor(deployment): make deployment service source-driven --- semapact/application/services/deployment.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/semapact/application/services/deployment.py b/semapact/application/services/deployment.py index 7661e2c4..18f6c08c 100644 --- a/semapact/application/services/deployment.py +++ b/semapact/application/services/deployment.py @@ -2,15 +2,13 @@ from __future__ import annotations -from semapact.contractops import AppliedContractRelease, ReleaseSnapshot from semapact.deployment import ( DeploymentAdapter, DeploymentAuthorization, DeploymentPlan, DeploymentPreview, DeploymentSourceSnapshot, - DeploymentTarget, - build_deployment_plan, + DeploymentTarget build_deployment_plan_from_source, ) from semapact.deployment.models import validate_deployment_authorization_identity @@ -23,12 +21,10 @@ class DeploymentService: def plan( self, - release: ReleaseSnapshot | AppliedContractRelease | DeploymentSourceSnapshot, + source: DeploymentSourceSnapshot, target: DeploymentTarget, ) -> DeploymentPlan: - if isinstance(release, DeploymentSourceSnapshot): - return build_deployment_plan_from_source(release, target) - return build_deployment_plan(release, target) + return build_deployment_plan_from_source(source, target) def preview( self, From cd118d68ceb5aedd7583d564d069d324208b33e1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:00:16 +1000 Subject: [PATCH 322/616] refactor(deployment): trim canonical deployment exports --- semapact/deployment/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index cbb8eab4..d0f4c73a 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -11,7 +11,6 @@ ) from semapact.deployment.authorization import ( authorize_candidate_deployment, - authorize_deployment, validate_candidate_deployment_context, validate_contract_release_deployment_context, ) @@ -29,14 +28,12 @@ from semapact.deployment.orchestrator import DeploymentOrchestrator from semapact.deployment.planner import ( build_deployment_actions, - build_deployment_plan, build_deployment_plan_from_source, ) from semapact.deployment.source import ( DeploymentSourceSnapshot, build_candidate_deployment_source, build_contract_release_deployment_source, - build_release_deployment_source, ) from semapact.deployment.providers import ( DeploymentExecutionConfig, From 466e2045f6546ab4a60afe0aa0aae0dfbbb5033a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:00:19 +1000 Subject: [PATCH 323/616] refactor(deployment): remove bundle compatibility accessor --- semapact/application/models/deployment_workflow.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py index aab7668f..49fc38d3 100644 --- a/semapact/application/models/deployment_workflow.py +++ b/semapact/application/models/deployment_workflow.py @@ -54,11 +54,6 @@ class DeploymentBundle(BaseModel): def is_release(self) -> bool: return self.deployment_source.source_kind == "contract_release" - @property - def release(self) -> bool: - """Compatibility accessor; canonical mode lives in deployment_source.""" - return self.is_release - @model_validator(mode="after") def _validate_bundle_links(self) -> "DeploymentBundle": source = self.deployment_source From 9a30a160797473bf651ac7947aec052cf64cd29b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:00:21 +1000 Subject: [PATCH 324/616] refactor(deployment): remove legacy authorization logic from canonical module --- semapact/deployment/authorization.py | 142 --------------------------- 1 file changed, 142 deletions(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index 01abc578..d931471c 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -6,13 +6,6 @@ from __future__ import annotations -from semapact.contractops.execution_models import AppliedContractRelease, ReleaseSnapshot -from semapact.contractops.integrity import ( - validate_applied_release_identity, - validate_contractops_authorization_identity, - validate_release_snapshot_identity, -) -from semapact.contractops.models import AuthorizationReason, ContractOpsAuthorization from semapact.deployment.models import ( DeploymentAuthorization, DeploymentPlan, @@ -26,90 +19,6 @@ from semapact.governance.gate import GovernanceOperation -def authorize_deployment( - plan: DeploymentPlan, - release: ReleaseSnapshot | AppliedContractRelease, - authorization: ContractOpsAuthorization, -) -> DeploymentAuthorization: - """Bind one DEPLOY authorization to the exact deployment target/plan.""" - if not isinstance(plan, DeploymentPlan): - raise TypeError(f"plan must be DeploymentPlan, got {type(plan).__name__}") - if not isinstance(release, (ReleaseSnapshot, AppliedContractRelease)): - raise TypeError( - "release must be ReleaseSnapshot or AppliedContractRelease, " - f"got {type(release).__name__}" - ) - if not isinstance(authorization, ContractOpsAuthorization): - raise TypeError( - "authorization must be ContractOpsAuthorization, " - f"got {type(authorization).__name__}" - ) - - validate_deployment_plan_identity(plan) - if isinstance(release, ReleaseSnapshot): - validate_release_snapshot_identity(release) - release_id = release.release_snapshot_id - else: - validate_applied_release_identity(release) - release_id = release.applied_release_id - validate_contractops_authorization_identity(authorization) - - if authorization.operation is not GovernanceOperation.DEPLOY: - raise ReleaseValidationError( - "Deployment requires operation-scoped DEPLOY authorization" - ) - - _validate_plan_release_context(plan, release) - _validate_authorization_release_context(authorization, release) - - if ( - authorization.allowed - and authorization.reason is AuthorizationReason.ALLOWED_BY_REVIEW - and authorization.scope_reference != plan.deployment_plan_id - ): - raise ReleaseValidationError( - "Review-based DEPLOY authorization is not scoped to this DeploymentPlan" - ) - - source_snapshot_id = plan.source_snapshot_id - if plan.plan_version in {"2", "3"}: - deployment_authorization_id = compute_deployment_authorization_id( - contract_ops_authorization_id=authorization.authorization_id, - deployment_plan_id=plan.deployment_plan_id, - applied_release_id=source_snapshot_id, - allowed=authorization.allowed, - authorization_version="1", - ) - return DeploymentAuthorization( - deployment_authorization_id=deployment_authorization_id, - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=source_snapshot_id, - allowed=authorization.allowed, - authorization_kind="contractops", - authorization_reference=authorization.authorization_id, - authorization_version="1", - ) - - deployment_authorization_id = compute_deployment_authorization_id( - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=source_snapshot_id, - authorization_kind="contractops", - authorization_reference=authorization.authorization_id, - allowed=authorization.allowed, - authorization_version="2", - ) - return DeploymentAuthorization( - deployment_authorization_id=deployment_authorization_id, - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=source_snapshot_id, - allowed=authorization.allowed, - authorization_kind="contractops", - authorization_reference=authorization.authorization_id, - authorization_version="2", - ) - - - def validate_candidate_deployment_context( plan: DeploymentPlan, source: DeploymentSourceSnapshot, @@ -218,54 +127,3 @@ def validate_contract_release_deployment_context( -def _validate_plan_release_context( - plan: DeploymentPlan, - release: ReleaseSnapshot | AppliedContractRelease, -) -> None: - release_id = ( - release.release_snapshot_id - if isinstance(release, ReleaseSnapshot) - else release.applied_release_id - ) - if plan.release_id != release_id: - raise ReleaseValidationError( - "DeploymentPlan does not reference the supplied exact release" - ) - if plan.contract_id != release.contract_id: - raise ReleaseValidationError( - "DeploymentPlan and release contract IDs do not match" - ) - if plan.release_plan_id != release.release_plan_id: - raise ReleaseValidationError( - "DeploymentPlan and release plan IDs do not match" - ) - if plan.released_revision_ref != release.release_revision_ref: - raise ReleaseValidationError( - "DeploymentPlan released revision does not match release" - ) - if plan.selected_version != release.selected_version: - raise ReleaseValidationError( - "DeploymentPlan selected version does not match release" - ) - - -def _validate_authorization_release_context( - authorization: ContractOpsAuthorization, - release: ReleaseSnapshot | AppliedContractRelease, -) -> None: - if authorization.decision_id != release.decision_id: - raise ReleaseValidationError( - "DEPLOY authorization decision does not match release" - ) - if authorization.change_set_id != release.change_set_id: - raise ReleaseValidationError( - "DEPLOY authorization ChangeSet does not match release" - ) - if authorization.release_plan_id != release.release_plan_id: - raise ReleaseValidationError( - "DEPLOY authorization ReleasePlan does not match release" - ) - if authorization.version_resolution_id != release.version_resolution_id: - raise ReleaseValidationError( - "DEPLOY authorization version resolution does not match release" - ) From 47c552a83fd5590aca5d417cc03a190489821d4c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:00:36 +1000 Subject: [PATCH 325/616] fix(deployment): repair canonical service imports --- semapact/application/services/deployment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/application/services/deployment.py b/semapact/application/services/deployment.py index 18f6c08c..8aeba817 100644 --- a/semapact/application/services/deployment.py +++ b/semapact/application/services/deployment.py @@ -8,7 +8,7 @@ DeploymentPlan, DeploymentPreview, DeploymentSourceSnapshot, - DeploymentTarget + DeploymentTarget, build_deployment_plan_from_source, ) from semapact.deployment.models import validate_deployment_authorization_identity From 5bc9cc75792b490035dd523be82f0cd4bc57cb4a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:00:39 +1000 Subject: [PATCH 326/616] refactor(deployment): clean canonical authorization boundary --- semapact/deployment/authorization.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index d931471c..be8fb810 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -9,14 +9,12 @@ from semapact.deployment.models import ( DeploymentAuthorization, DeploymentPlan, - compute_deployment_authorization_id, validate_deployment_plan_identity, ) from semapact.deployment.source import DeploymentSourceSnapshot from semapact.contractops import ContractRelease from semapact.governance import DecisionResult, GovernanceDecision from semapact.exceptions import ReleaseValidationError -from semapact.governance.gate import GovernanceOperation def validate_candidate_deployment_context( From f4f2e8fcd11f536939cabb996fae0df929a668ca Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:01:03 +1000 Subject: [PATCH 327/616] refactor(deployment): use canonical provenance in legacy history service --- semapact/application/services/deployment_history.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/semapact/application/services/deployment_history.py b/semapact/application/services/deployment_history.py index ef96a50d..f1439dbb 100644 --- a/semapact/application/services/deployment_history.py +++ b/semapact/application/services/deployment_history.py @@ -66,7 +66,7 @@ def record_execution( release = self._releases.get_release_record_by_version( plan.contract_id, - plan.selected_version, + plan.contract_version, ) _validate_release_link(release, plan) _validate_preview_link(preview, plan) @@ -140,9 +140,8 @@ def _validate_types( def _validate_release_link(release: ReleaseRecord, plan: DeploymentPlan) -> None: if ( release.contract_id != plan.contract_id - or release.contract_version != plan.selected_version - or release.release_plan_id != plan.release_plan_id - or release.applied_release_id != plan.applied_release_id + or release.contract_version != plan.contract_version + or release.applied_release_id != plan.source_snapshot_id ): raise ValueError("DeploymentPlan does not match the finalized ReleaseRecord") @@ -166,9 +165,9 @@ def _validate_authorization_link( raise ValueError( "DeploymentAuthorization does not reference the supplied DeploymentPlan" ) - if authorization.applied_release_id != plan.applied_release_id: + if authorization.source_snapshot_id != plan.source_snapshot_id: raise ValueError( - "DeploymentAuthorization does not reference the plan's applied release" + "DeploymentAuthorization does not reference the plan source snapshot" ) if not authorization.allowed: raise ValueError("deployment history requires an allowed DeploymentAuthorization") From 64aa696049a3b20e3d0a66ec653a38733ef62c71 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:01:16 +1000 Subject: [PATCH 328/616] test(deployment): use canonical deployment artifacts --- tests/test_deployment_databricks.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/test_deployment_databricks.py b/tests/test_deployment_databricks.py index cbd8faca..11d39cd4 100644 --- a/tests/test_deployment_databricks.py +++ b/tests/test_deployment_databricks.py @@ -72,33 +72,30 @@ def _plan(*properties: SchemaProperty, source_reference: str = SOURCE_REFERENCE) source_reference=source_reference, ) plan_id = compute_deployment_plan_id( - applied_release_id="applied:test", + source_snapshot_id="applied:test", contract_id="orders-product", - release_plan_id="release-plan:test", - released_revision_ref="rev:released", - selected_version="1.2.0", + revision_ref="rev:released", + contract_version="1.2.0", target=target, actions=(action,), ) return DeploymentPlan( deployment_plan_id=plan_id, source_snapshot_id="applied:test", - release_id="applied:test", contract_id="orders-product", - release_plan_id="release-plan:test", revision_ref="rev:released", contract_version="1.2.0", target=target, actions=(action,), - plan_version="2", ) def _authorization(plan: DeploymentPlan, allowed: bool = True) -> DeploymentAuthorization: authorization_id = compute_deployment_authorization_id( - contract_ops_authorization_id="contractops-auth:test", + authorization_kind="contractops", + authorization_reference="contractops-auth:test", deployment_plan_id=plan.deployment_plan_id, - applied_release_id=plan.applied_release_id, + source_snapshot_id=plan.source_snapshot_id, allowed=allowed, ) return DeploymentAuthorization( @@ -108,7 +105,6 @@ def _authorization(plan: DeploymentPlan, allowed: bool = True) -> DeploymentAuth allowed=allowed, authorization_kind="contractops", authorization_reference="contractops-auth:test", - authorization_version="1", ) From 45c61867436e6e6a79032124e1b37bf3fcfc7f67 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:01:19 +1000 Subject: [PATCH 329/616] test(deployment): use canonical deployment artifacts --- tests/test_deployment_orchestrator.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/test_deployment_orchestrator.py b/tests/test_deployment_orchestrator.py index 33fc9fdc..0ceceb56 100644 --- a/tests/test_deployment_orchestrator.py +++ b/tests/test_deployment_orchestrator.py @@ -146,25 +146,21 @@ def _plan() -> DeploymentPlan: source_reference="source-1", ) identity_kwargs = dict( - applied_release_id="release-1", + source_snapshot_id="release-1", contract_id="contract-1", - release_plan_id="release-plan-1", - released_revision_ref="revision-1", - selected_version="1.0.0", + revision_ref="revision-1", + contract_version="1.0.0", target=target, actions=(action,), ) return DeploymentPlan( deployment_plan_id=compute_deployment_plan_id(**identity_kwargs), source_snapshot_id="release-1", - release_id="release-1", contract_id="contract-1", - release_plan_id="release-plan-1", revision_ref="revision-1", contract_version="1.0.0", target=target, actions=(action,), - plan_version="2", ) @@ -201,9 +197,10 @@ def test_generic_orchestrator_owns_observe_preview_freshness_and_execute() -> No assert preview.operations[0].statement == "CREATE_ASSET orders" authorization_id = compute_deployment_authorization_id( - contract_ops_authorization_id="contract-auth-1", + authorization_kind="contractops", + authorization_reference="contract-auth-1", deployment_plan_id=plan.deployment_plan_id, - applied_release_id=plan.applied_release_id, + source_snapshot_id=plan.source_snapshot_id, allowed=True, ) authorization = DeploymentAuthorization( @@ -213,7 +210,6 @@ def test_generic_orchestrator_owns_observe_preview_freshness_and_execute() -> No allowed=True, authorization_kind="contractops", authorization_reference="contract-auth-1", - authorization_version="1", ) orchestrator.execute(plan, preview, authorization) From 5697ff6a19efbe24af13907d8b7c5ad85b56dddf Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:01:21 +1000 Subject: [PATCH 330/616] test(deployment): use canonical deployment artifacts --- tests/test_deployment_service.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tests/test_deployment_service.py b/tests/test_deployment_service.py index 361d2d27..eac3cbb7 100644 --- a/tests/test_deployment_service.py +++ b/tests/test_deployment_service.py @@ -98,25 +98,21 @@ def _plan() -> DeploymentPlan: source_reference=SOURCE_REFERENCE, ) plan_id = compute_deployment_plan_id( - applied_release_id="release-1", + source_snapshot_id="release-1", contract_id="orders-contract", - release_plan_id="release-plan-1", - released_revision_ref="abc123", - selected_version="1.2.3", + revision_ref="abc123", + contract_version="1.2.3", target=target, actions=(action,), ) return DeploymentPlan( deployment_plan_id=plan_id, source_snapshot_id="release-1", - release_id="release-1", contract_id="orders-contract", - release_plan_id="release-plan-1", revision_ref="abc123", contract_version="1.2.3", target=target, actions=(action,), - plan_version="2", ) @@ -163,7 +159,7 @@ def _verification(plan: DeploymentPlan) -> ReconciliationResult: assert observation.fingerprint is not None return ReconciliationResult( contract_id=plan.contract_id, - contract_version=plan.selected_version, + contract_version=plan.contract_version, observation_source_identifier=observation.source_identifier, observation_fingerprint=observation.fingerprint, ) @@ -178,9 +174,10 @@ def _adapter(plan: DeploymentPlan) -> FakeDeploymentAdapter: def _authorization(plan: DeploymentPlan) -> DeploymentAuthorization: authorization_id = compute_deployment_authorization_id( - contract_ops_authorization_id="contractops-auth-1", + authorization_kind="contractops", + authorization_reference="contractops-auth-1", deployment_plan_id=plan.deployment_plan_id, - applied_release_id=plan.applied_release_id, + source_snapshot_id=plan.source_snapshot_id, allowed=True, ) return DeploymentAuthorization( @@ -190,7 +187,6 @@ def _authorization(plan: DeploymentPlan) -> DeploymentAuthorization: allowed=True, authorization_kind="contractops", authorization_reference="contractops-auth-1", - authorization_version="1", ) From 0daff1242290df48222e7c5eb127dd2e3987f006 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:01:24 +1000 Subject: [PATCH 331/616] test(deployment): use canonical deployment artifacts --- tests/test_deployment_verification.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/test_deployment_verification.py b/tests/test_deployment_verification.py index 86c35eee..bf7c859c 100644 --- a/tests/test_deployment_verification.py +++ b/tests/test_deployment_verification.py @@ -82,25 +82,21 @@ def _plan() -> DeploymentPlan: source_reference=SOURCE_REFERENCE, ) plan_id = compute_deployment_plan_id( - applied_release_id="release-1", + source_snapshot_id="release-1", contract_id="orders-contract", - release_plan_id="release-plan-1", - released_revision_ref="abc123", - selected_version="1.2.3", + revision_ref="abc123", + contract_version="1.2.3", target=target, actions=(action,), ) return DeploymentPlan( deployment_plan_id=plan_id, source_snapshot_id="release-1", - release_id="release-1", contract_id="orders-contract", - release_plan_id="release-plan-1", revision_ref="abc123", contract_version="1.2.3", target=target, actions=(action,), - plan_version="2", ) From f71ff8e8274eccf3130b9885eced36a9e6da4ccb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:01:26 +1000 Subject: [PATCH 332/616] test(deployment): use canonical deployment artifacts --- tests/test_deployment_history.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/test_deployment_history.py b/tests/test_deployment_history.py index 2b008e55..db5c2123 100644 --- a/tests/test_deployment_history.py +++ b/tests/test_deployment_history.py @@ -61,25 +61,21 @@ def _deployment_artifacts(*, preview_runtime_target: str = "catalog.schema", all source_reference="workspace:test", ) plan_id = compute_deployment_plan_id( - applied_release_id="applied-release-1", + source_snapshot_id="applied-release-1", contract_id="orders-product", - release_plan_id="release-plan-1", - released_revision_ref="candidate-revision-1", - selected_version="1.3.0", + revision_ref="candidate-revision-1", + contract_version="1.3.0", target=target, actions=(), ) plan = DeploymentPlan( deployment_plan_id=plan_id, source_snapshot_id="applied-release-1", - release_id="applied-release-1", contract_id="orders-product", - release_plan_id="release-plan-1", revision_ref="candidate-revision-1", contract_version="1.3.0", target=target, actions=(), - plan_version="2", ) preview_id = compute_deployment_preview_id( @@ -101,9 +97,10 @@ def _deployment_artifacts(*, preview_runtime_target: str = "catalog.schema", all ) authorization_id = compute_deployment_authorization_id( - contract_ops_authorization_id="deploy-authorization-1", + authorization_kind="contractops", + authorization_reference="deploy-authorization-1", deployment_plan_id=plan.deployment_plan_id, - applied_release_id=plan.applied_release_id, + source_snapshot_id=plan.source_snapshot_id, allowed=allowed, ) authorization = DeploymentAuthorization( @@ -113,7 +110,6 @@ def _deployment_artifacts(*, preview_runtime_target: str = "catalog.schema", all allowed=allowed, authorization_kind="contractops", authorization_reference="deploy-authorization-1", - authorization_version="1", ) return plan, preview, authorization From c6cab29e26ecab19ce505eebba9ff2f89f62b195 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:01:29 +1000 Subject: [PATCH 333/616] test(deployment): use canonical deployment artifacts --- tests/test_databricks_convergence_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_databricks_convergence_e2e.py b/tests/test_databricks_convergence_e2e.py index e43ce188..53184c75 100644 --- a/tests/test_databricks_convergence_e2e.py +++ b/tests/test_databricks_convergence_e2e.py @@ -606,7 +606,7 @@ def test_denied_deployment_authorization_fails_before_native_mutation() -> None: workflow.authorization.contract_ops_authorization_id ), deployment_plan_id=workflow.plan.deployment_plan_id, - applied_release_id=workflow.plan.applied_release_id, + source_snapshot_id=workflow.plan.source_snapshot_id, allowed=False, ), } From c066d444071659d8146df81d0a577c4cd64e4110 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:02:01 +1000 Subject: [PATCH 334/616] refactor(deployment): isolate legacy authorization adapter --- semapact/deployment/compatibility.py | 85 ++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/semapact/deployment/compatibility.py b/semapact/deployment/compatibility.py index 8619eca9..1f3743f2 100644 --- a/semapact/deployment/compatibility.py +++ b/semapact/deployment/compatibility.py @@ -11,6 +11,8 @@ from typing import Any from semapact.contractops.execution_models import AppliedContractRelease, ReleaseSnapshot +from semapact.contractops.integrity import validate_contractops_authorization_identity +from semapact.contractops.models import AuthorizationReason, ContractOpsAuthorization from semapact.contractops.integrity import ( validate_applied_release_identity, validate_release_snapshot_identity, @@ -26,6 +28,8 @@ compute_deployment_plan_id, ) from semapact.deployment.planner import build_deployment_actions +from semapact.exceptions import ReleaseValidationError +from semapact.governance.gate import GovernanceOperation from semapact.utils.deterministic import deterministic_uuid5 @@ -200,6 +204,87 @@ def build_legacy_deployment_plan( ) + +def authorize_legacy_deployment( + plan: DeploymentPlan, + release: ReleaseSnapshot | AppliedContractRelease, + authorization: ContractOpsAuthorization, +) -> DeploymentAuthorization: + """Adapt the legacy in-process DEPLOY authorization path to canonical v2.""" + if isinstance(release, ReleaseSnapshot): + validate_release_snapshot_identity(release) + release_id = release.release_snapshot_id + elif isinstance(release, AppliedContractRelease): + validate_applied_release_identity(release) + release_id = release.applied_release_id + else: + raise TypeError( + "release must be ReleaseSnapshot or AppliedContractRelease, " + f"got {type(release).__name__}" + ) + validate_contractops_authorization_identity(authorization) + + if authorization.operation is not GovernanceOperation.DEPLOY: + raise ReleaseValidationError( + "Deployment requires operation-scoped DEPLOY authorization" + ) + if plan.source_snapshot_id != release_id: + raise ReleaseValidationError( + "DeploymentPlan does not reference the supplied exact legacy release" + ) + if plan.contract_id != release.contract_id: + raise ReleaseValidationError( + "DeploymentPlan and legacy release contract IDs do not match" + ) + if plan.revision_ref != release.release_revision_ref: + raise ReleaseValidationError( + "DeploymentPlan revision does not match legacy release" + ) + if plan.contract_version != release.selected_version: + raise ReleaseValidationError( + "DeploymentPlan version does not match legacy release" + ) + if authorization.decision_id != release.decision_id: + raise ReleaseValidationError( + "DEPLOY authorization decision does not match legacy release" + ) + if authorization.change_set_id != release.change_set_id: + raise ReleaseValidationError( + "DEPLOY authorization ChangeSet does not match legacy release" + ) + if authorization.release_plan_id != release.release_plan_id: + raise ReleaseValidationError( + "DEPLOY authorization ReleasePlan does not match legacy release" + ) + if authorization.version_resolution_id != release.version_resolution_id: + raise ReleaseValidationError( + "DEPLOY authorization version resolution does not match legacy release" + ) + if ( + authorization.allowed + and authorization.reason is AuthorizationReason.ALLOWED_BY_REVIEW + and authorization.scope_reference != plan.deployment_plan_id + ): + raise ReleaseValidationError( + "Review-based DEPLOY authorization is not scoped to this DeploymentPlan" + ) + + authorization_id = compute_deployment_authorization_id( + deployment_plan_id=plan.deployment_plan_id, + source_snapshot_id=plan.source_snapshot_id, + authorization_kind="contractops", + authorization_reference=authorization.authorization_id, + allowed=authorization.allowed, + ) + return DeploymentAuthorization( + deployment_authorization_id=authorization_id, + deployment_plan_id=plan.deployment_plan_id, + source_snapshot_id=plan.source_snapshot_id, + allowed=authorization.allowed, + authorization_kind="contractops", + authorization_reference=authorization.authorization_id, + ) + def _infer_plan_version(value: Mapping[str, Any]) -> str: raw = value.get("plan_version") if raw is not None: From d22ea3c7f15fccf3f437f81943a027bca4813ce4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:02:22 +1000 Subject: [PATCH 335/616] refactor(deployment): use canonical contract version --- semapact/deployment/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/deployment/verification.py b/semapact/deployment/verification.py index 2d1ae047..07829e39 100644 --- a/semapact/deployment/verification.py +++ b/semapact/deployment/verification.py @@ -79,6 +79,6 @@ def _contract_projection_from_plan( # deterministic plan identity was revalidated above. return OpenDataContractStandard.model_construct( id=plan.contract_id, - version=plan.selected_version, + version=plan.contract_version, schema_=schemas, ) From 8246336db57c0a22b30f7e8931286ec3005b7ee1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:02:40 +1000 Subject: [PATCH 336/616] test(deployment): isolate legacy plan compatibility coverage --- tests/test_deployment_plan.py | 155 +++++++++++++++------------------- 1 file changed, 70 insertions(+), 85 deletions(-) diff --git a/tests/test_deployment_plan.py b/tests/test_deployment_plan.py index 12c788a8..e9f75582 100644 --- a/tests/test_deployment_plan.py +++ b/tests/test_deployment_plan.py @@ -16,15 +16,18 @@ compute_release_snapshot_id, ) from semapact.deployment.compatibility import ( + build_legacy_deployment_plan, parse_deployment_plan_payload, serialize_deployment_plan_payload, ) -from semapact.deployment import ( +from semapact.deployment.models import ( + SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, DeploymentAction, DeploymentActionKind, + DeploymentPlan, DeploymentTarget, - build_deployment_plan, ) +from semapact.utils.deterministic import deterministic_uuid5 def _schema( @@ -112,26 +115,28 @@ def _target() -> DeploymentTarget: ) -def test_release_snapshot_produces_v3_plan_without_apply_authorization() -> None: +def test_legacy_release_snapshot_is_adapted_to_canonical_v5_plan() -> None: snapshot = _snapshot() - plan = build_deployment_plan(snapshot, _target()) + plan = build_legacy_deployment_plan(snapshot, _target()) - assert plan.release_id == snapshot.release_snapshot_id - assert plan.plan_version == "3" + assert plan.plan_version == "5" + assert plan.source_snapshot_id == snapshot.release_snapshot_id assert plan.contract_id == snapshot.contract_id - assert plan.selected_version == snapshot.selected_version + assert plan.contract_version == snapshot.selected_version payload = serialize_deployment_plan_payload(plan) - assert payload["release_id"] == snapshot.release_snapshot_id + assert payload["plan_version"] == "5" + assert "release_id" not in payload + assert "release_plan_id" not in payload assert "applied_release_id" not in payload - assert parse_deployment_plan_payload(payload) == plan + assert "selected_version" not in payload -def test_same_exact_release_and_target_produce_same_plan() -> None: +def test_same_exact_legacy_release_and_target_produce_same_canonical_plan() -> None: release = _release(schemas=[_schema("zeta"), _schema("alpha")]) - first = build_deployment_plan(release, _target()) - second = build_deployment_plan(release, _target()) + first = build_legacy_deployment_plan(release, _target()) + second = build_legacy_deployment_plan(release, _target()) assert first == second assert first.deployment_plan_id == second.deployment_plan_id @@ -139,26 +144,56 @@ def test_same_exact_release_and_target_produce_same_plan() -> None: assert [action.governed_asset for action in first.actions] == ["alpha", "zeta"] -def test_plan_preserves_exact_applied_release_provenance() -> None: +def test_legacy_v2_wire_payload_upgrades_to_canonical_identity() -> None: release = _release() + canonical = build_legacy_deployment_plan(release, _target()) + legacy_payload = { + "applied_release_id": release.applied_release_id, + "contract_id": release.contract_id, + "release_plan_id": release.release_plan_id, + "released_revision_ref": release.release_revision_ref, + "selected_version": release.selected_version, + "target": canonical.target.model_dump(mode="json"), + "actions": [action.model_dump(mode="json") for action in canonical.actions], + "plan_version": "2", + } + legacy_payload["deployment_plan_id"] = deterministic_uuid5( + SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, + { + **legacy_payload, + "target": canonical.target.model_dump(mode="json"), + "actions": [action.model_dump(mode="json") for action in canonical.actions], + }, + ) - plan = build_deployment_plan(release, _target()) + upgraded = parse_deployment_plan_payload(legacy_payload) - assert plan.applied_release_id == release.applied_release_id - assert plan.contract_id == release.contract_id - assert plan.release_plan_id == release.release_plan_id - assert plan.released_revision_ref == release.release_revision_ref - assert plan.selected_version == release.selected_version - assert plan.plan_version == "2" - assert plan.target.source_reference == "https://workspace.example" - payload = serialize_deployment_plan_payload(plan) - assert payload["applied_release_id"] == release.applied_release_id - assert "release_id" not in payload - assert parse_deployment_plan_payload(payload) == plan + assert upgraded == canonical + assert upgraded.plan_version == "5" + assert upgraded.deployment_plan_id != legacy_payload["deployment_plan_id"] + + +def test_legacy_v2_wire_payload_tamper_fails_closed() -> None: + release = _release() + canonical = build_legacy_deployment_plan(release, _target()) + legacy_payload = { + "deployment_plan_id": "legacy:stale", + "applied_release_id": release.applied_release_id, + "contract_id": release.contract_id, + "release_plan_id": release.release_plan_id, + "released_revision_ref": release.release_revision_ref, + "selected_version": release.selected_version, + "target": canonical.target.model_dump(mode="json"), + "actions": [action.model_dump(mode="json") for action in canonical.actions], + "plan_version": "2", + } + + with pytest.raises(ValueError, match="Legacy DeploymentPlan deterministic identity"): + parse_deployment_plan_payload(legacy_payload) def test_actions_are_provider_neutral_ensure_state_intents() -> None: - plan = build_deployment_plan( + plan = build_legacy_deployment_plan( _release(schemas=[_schema("orders"), _schema("customers")]), _target(), ) @@ -168,11 +203,10 @@ def test_actions_are_provider_neutral_ensure_state_intents() -> None: action.kind is DeploymentActionKind.ENSURE_ASSET_STATE for action in plan.actions ) - assert {action.kind.value for action in plan.actions} == {"ENSURE_ASSET_STATE"} def test_physical_name_is_binding_hint_not_governed_identity() -> None: - plan = build_deployment_plan( + plan = build_legacy_deployment_plan( _release(schemas=[_schema("Orders", physical_name="prod_orders_v2")]), _target(), ) @@ -181,27 +215,12 @@ def test_physical_name_is_binding_hint_not_governed_identity() -> None: assert action.governed_asset == "orders" assert action.physical_name == "prod_orders_v2" - desired = SchemaObject.model_validate_json(action.desired_state_json) - assert desired.name == "Orders" - assert desired.physicalName == "prod_orders_v2" - - -def test_missing_physical_name_falls_back_to_governed_schema_name() -> None: - plan = build_deployment_plan( - _release(schemas=[_schema("Orders")]), - _target(), - ) - - action = plan.actions[0] - assert action.governed_asset == "orders" - assert action.physical_name == "Orders" - -def test_target_is_explicit_and_changes_plan_identity() -> None: +def test_target_changes_canonical_plan_identity() -> None: release = _release() - production = build_deployment_plan(release, _target()) - staging = build_deployment_plan( + production = build_legacy_deployment_plan(release, _target()) + staging = build_legacy_deployment_plan( release, DeploymentTarget( platform="databricks", @@ -212,42 +231,6 @@ def test_target_is_explicit_and_changes_plan_identity() -> None: ) assert production.deployment_plan_id != staging.deployment_plan_id - assert production.target.platform == "databricks" - assert staging.target.runtime_target == "main.staging" - - -def test_runtime_source_changes_plan_identity_for_same_runtime_target() -> None: - release = _release() - first = build_deployment_plan(release, _target()) - second = build_deployment_plan( - release, - DeploymentTarget( - platform="databricks", - runtime_target="main.analytics", - source_reference="https://other-workspace.example", - server_name="production", - ), - ) - - assert first.deployment_plan_id != second.deployment_plan_id - - -def test_schema_order_is_canonicalized_within_each_exact_release() -> None: - first_release = _release( - schemas=[_schema("zeta"), _schema("alpha")], - ) - second_release = _release( - schemas=[_schema("alpha"), _schema("zeta")], - ) - - first = build_deployment_plan(first_release, _target()) - second = build_deployment_plan(second_release, _target()) - - assert [action.governed_asset for action in first.actions] == ["alpha", "zeta"] - assert [action.governed_asset for action in second.actions] == ["alpha", "zeta"] - # Exact release snapshot identity remains authoritative; two releases with - # differently serialized source schema order remain distinct authorities. - assert first.deployment_plan_id != second.deployment_plan_id def test_desired_state_identity_mismatch_fails_closed() -> None: @@ -267,8 +250,10 @@ def test_desired_state_identity_mismatch_fails_closed() -> None: ) -def test_deployment_models_are_immutable() -> None: - plan = build_deployment_plan(_release(), _target()) +def test_canonical_deployment_plan_rejects_legacy_fields() -> None: + plan = build_legacy_deployment_plan(_release(), _target()) + payload = plan.model_dump(mode="json") + payload["release_id"] = "legacy-release" with pytest.raises(PydanticValidationError): - setattr(plan, "selected_version", "9.9.9") + DeploymentPlan.model_validate(payload) From 063c8eb9d0ac277528091885fb7fbfd0464bb330 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:02:53 +1000 Subject: [PATCH 337/616] test(deployment): verify legacy authorization upgrade --- tests/test_deployment_service.py | 36 +++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/tests/test_deployment_service.py b/tests/test_deployment_service.py index eac3cbb7..11dd09a0 100644 --- a/tests/test_deployment_service.py +++ b/tests/test_deployment_service.py @@ -24,11 +24,13 @@ serialize_deployment_authorization_payload, ) from semapact.deployment.models import ( + SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, compute_deployment_authorization_id, compute_deployment_plan_id, compute_deployment_preview_id, ) from semapact.exceptions import ValidationError +from semapact.utils.deterministic import deterministic_uuid5 from semapact.observation import ObservedPlatformState, with_observed_state_fingerprint from semapact.reconciliation import ReconciliationResult from semapact.services.deployment_service import DeploymentService @@ -194,14 +196,32 @@ def _authorization(plan: DeploymentPlan) -> DeploymentAuthorization: def test_legacy_authorization_wire_format_is_explicitly_adapted() -> None: plan = _plan() - authorization = _authorization(plan) - - payload = serialize_deployment_authorization_payload(authorization) - - assert payload["contract_ops_authorization_id"] == "contractops-auth-1" - assert payload["applied_release_id"] == plan.source_snapshot_id - assert "source_snapshot_id" not in payload - assert parse_deployment_authorization_payload(payload) == authorization + legacy_id = deterministic_uuid5( + SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, + { + "contract_ops_authorization_id": "contractops-auth-1", + "deployment_plan_id": plan.deployment_plan_id, + "applied_release_id": plan.source_snapshot_id, + "allowed": True, + }, + ) + payload = { + "deployment_authorization_id": legacy_id, + "contract_ops_authorization_id": "contractops-auth-1", + "deployment_plan_id": plan.deployment_plan_id, + "applied_release_id": plan.source_snapshot_id, + "allowed": True, + } + + upgraded = parse_deployment_authorization_payload(payload) + + assert upgraded.authorization_version == "2" + assert upgraded.source_snapshot_id == plan.source_snapshot_id + assert upgraded.authorization_reference == "contractops-auth-1" + assert serialize_deployment_authorization_payload(upgraded) == upgraded.model_dump( + mode="json" + ) + assert upgraded.deployment_authorization_id != legacy_id def test_preview_delegates_to_unified_adapter_entrypoint() -> None: From 8590f8b4d545e3337f935cacdb9ac49a1659dfdd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:02:59 +1000 Subject: [PATCH 338/616] test(deployment): route legacy e2e through compatibility seam --- tests/test_databricks_convergence_e2e.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_databricks_convergence_e2e.py b/tests/test_databricks_convergence_e2e.py index 53184c75..d5e08d48 100644 --- a/tests/test_databricks_convergence_e2e.py +++ b/tests/test_databricks_convergence_e2e.py @@ -29,7 +29,10 @@ DeploymentPlan, DeploymentPreview, DeploymentTarget, - authorize_deployment, +) +from semapact.deployment.compatibility import ( + authorize_legacy_deployment, + build_legacy_deployment_plan, ) from semapact.deployment.models import ( NativeOperationKind, @@ -319,8 +322,8 @@ def _workflow( ): release_context = _release_context(*properties) service = DeploymentService() - plan = service.plan(release_context.release, _target()) - authorization = authorize_deployment( + plan = build_legacy_deployment_plan(release_context.release, _target()) + authorization = authorize_legacy_deployment( plan, release_context.release, release_context.deploy_authorization, From e79ef1a1b253e418d1bd1a872ce28e09434725fb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:03:22 +1000 Subject: [PATCH 339/616] test(deployment): use canonical authorization identity --- tests/test_databricks_convergence_e2e.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_databricks_convergence_e2e.py b/tests/test_databricks_convergence_e2e.py index d5e08d48..db34407d 100644 --- a/tests/test_databricks_convergence_e2e.py +++ b/tests/test_databricks_convergence_e2e.py @@ -605,11 +605,10 @@ def test_denied_deployment_authorization_fails_before_native_mutation() -> None: update={ "allowed": False, "deployment_authorization_id": compute_deployment_authorization_id( - contract_ops_authorization_id=( - workflow.authorization.contract_ops_authorization_id - ), deployment_plan_id=workflow.plan.deployment_plan_id, source_snapshot_id=workflow.plan.source_snapshot_id, + authorization_kind="contractops", + authorization_reference=workflow.authorization.authorization_reference, allowed=False, ), } From 3374eaf1b4be7bea7ca28f42d227fead6d0108ff Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:03:24 +1000 Subject: [PATCH 340/616] test(deployment): assert canonical plan has no release duplication --- tests/test_deployment_workflow_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 01b8f873..d472121d 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -256,7 +256,7 @@ def test_finalized_release_assessment_binds_exact_release_record(tmp_path) -> No assert bundle.contract_release == release assert bundle.deployment_source.release_id == release.contract_release_id assert bundle.deployment_source.contract_version == release.contract_version - assert bundle.deployment_plan.release_id is None + assert not hasattr(bundle.deployment_plan, "release_id") def test_same_candidate_inputs_produce_same_bundle_digest() -> None: @@ -411,4 +411,4 @@ def test_release_deployment_projects_finalized_version_after_in_sync(tmp_path) - plan, metadata = adapter.metadata_calls[0] assert metadata.contract_version == release.contract_version assert metadata.contract_release_id == release.contract_release_id - assert plan.release_id is None + assert not hasattr(plan, "release_id") From 8e1e878d6d92a9e549963307acb75a86f3e916a2 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:04:11 +1000 Subject: [PATCH 341/616] fix(deployment): restore canonical authorization identity helper --- semapact/deployment/authorization.py | 1 + 1 file changed, 1 insertion(+) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index be8fb810..ff465c85 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -9,6 +9,7 @@ from semapact.deployment.models import ( DeploymentAuthorization, DeploymentPlan, + compute_deployment_authorization_id, validate_deployment_plan_identity, ) from semapact.deployment.source import DeploymentSourceSnapshot From 2cd4cbd54dc211947f43fcabdb06f5448f1bdfb5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:05:11 +1000 Subject: [PATCH 342/616] fix(deployment): preserve legacy import aliases behind compatibility seam --- semapact/deployment/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index d0f4c73a..b1fd5667 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -14,6 +14,10 @@ validate_candidate_deployment_context, validate_contract_release_deployment_context, ) +from semapact.deployment.compatibility import ( + authorize_legacy_deployment as authorize_deployment, + build_legacy_deployment_plan as build_deployment_plan, +) from semapact.deployment.compilers import TransitionCompiler from semapact.deployment.models import ( DeploymentAction, From d500a15570175ee8c698a0fdca87c14e795184b9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:06:29 +1000 Subject: [PATCH 343/616] test(history): refresh deployment artifact golden fixture --- .../v1/review_multi_deploy.json | 337 +++++++++++++----- 1 file changed, 249 insertions(+), 88 deletions(-) diff --git a/tests/fixtures/history_golden/v1/review_multi_deploy.json b/tests/fixtures/history_golden/v1/review_multi_deploy.json index 02d2e165..0386d45f 100644 --- a/tests/fixtures/history_golden/v1/review_multi_deploy.json +++ b/tests/fixtures/history_golden/v1/review_multi_deploy.json @@ -1,132 +1,293 @@ { - "fixtureVersion": "history-v1", + "evolution": { + "brokenReferences": [], + "contractId": "orders-product", + "proposals": [ + { + "baseRevisionId": "fb437fde-4baa-5dad-a382-c4df381ba412", + "candidateRevisionId": "352d83a9-4ee6-508a-981f-4bc9819263c9", + "changeSetId": "c2f4ee02-90e5-532a-ac8a-edd43e49f156", + "decision": "REVIEW", + "decisionId": "14295d1b-b52d-5d8b-8dad-2121529f099c", + "releases": [ + { + "contractVersion": "1.3.0", + "deployments": [ + { + "deploymentRecordId": "2f2c8752-0abf-5d19-b097-af057b4a0fe1", + "runtime": [], + "runtimeTarget": "main.stage", + "status": "FAILED" + }, + { + "deploymentRecordId": "7fe4b74d-604c-58ec-94ba-f469651ea25a", + "runtime": [ + { + "observationRecordId": "38695939-4fb5-583c-acfa-1a21397d89c8", + "reconciliationRecordId": "1e5b9772-02f3-5461-9a7b-a8cf73f6a609", + "status": "DRIFT" + }, + { + "observationRecordId": "e259c151-972f-532a-99d1-a14dcd78b166", + "reconciliationRecordId": "7d8fcefb-3470-590b-ba48-1f1f9910be85", + "status": "IN_SYNC" + } + ], + "runtimeTarget": "main.prod", + "status": "SUCCEEDED" + } + ], + "releaseRecordId": "de7c4ed0-d54a-5a7a-b82e-c626eab39d23", + "releasedRevisionId": "290bd707-017f-56ca-a379-c8879b9ede13" + } + ] + } + ], + "unlinkedReleaseIds": [], + "unlinkedRuntimeIds": [] + }, "files": { ".semapact/history/change_set_decisions/c2f4ee02-90e5-532a-ac8a-edd43e49f156/14295d1b-b52d-5d8b-8dad-2121529f099c.json": { "checksum": "sha256:d77324f66e332dfdd6cb3ff081e65987a6eaa469d5b84bab2cb425d55e23dd62", - "topLevelKeys": ["change_set_id", "decision_id"] + "topLevelKeys": [ + "change_set_id", + "decision_id" + ] }, ".semapact/history/change_sets/c2f4ee02-90e5-532a-ac8a-edd43e49f156.json": { "checksum": "sha256:66861c622ee7f42f4340a761d17c16a006bf738ad682c39359c552376ce95005", - "topLevelKeys": ["actor_reference", "base_revision_ref", "candidate_revision_ref", "change_set_id", "changes", "context", "contract_id", "source"] + "topLevelKeys": [ + "actor_reference", + "base_revision_ref", + "candidate_revision_ref", + "change_set_id", + "changes", + "context", + "contract_id", + "source" + ] }, ".semapact/history/contract_revisions/290bd707-017f-56ca-a379-c8879b9ede13.json": { "checksum": "sha256:e70f5404098efaf4cec40f9a021adbba17cad53ed0c57db6984126fdcb84373e", - "topLevelKeys": ["content_fingerprint", "contract", "revision_id"] + "topLevelKeys": [ + "content_fingerprint", + "contract", + "revision_id" + ] }, ".semapact/history/contract_revisions/352d83a9-4ee6-508a-981f-4bc9819263c9.json": { "checksum": "sha256:bf1e3e1836fff7827bc014caccfefd36a01aaf480ecd17eae41c9a8245e5eb9e", - "topLevelKeys": ["content_fingerprint", "contract", "revision_id"] + "topLevelKeys": [ + "content_fingerprint", + "contract", + "revision_id" + ] }, ".semapact/history/contract_revisions/fb437fde-4baa-5dad-a382-c4df381ba412.json": { "checksum": "sha256:be036b579fe23c3d47afb96978c1e05416564b5ad0e30df034803c2c86bbf9c2", - "topLevelKeys": ["content_fingerprint", "contract", "revision_id"] + "topLevelKeys": [ + "content_fingerprint", + "contract", + "revision_id" + ] }, ".semapact/history/decisions/14295d1b-b52d-5d8b-8dad-2121529f099c.json": { "checksum": "sha256:5c0ddb33910feb477c32252d21c318e273e031185f52f4295aa0074dd78bf485", - "topLevelKeys": ["breaking", "changes", "context", "contract_id", "decision", "decision_id", "evidence", "policy", "reasons", "required_version_bump", "validation"] + "topLevelKeys": [ + "breaking", + "changes", + "context", + "contract_id", + "decision", + "decision_id", + "evidence", + "policy", + "reasons", + "required_version_bump", + "validation" + ] }, - ".semapact/history/deployment_authorizations/87ea9883-2583-51d7-8c25-fe4d1c643dc0.json": { - "checksum": "sha256:7a2ea0240736846da3a701253ad913f8a048066e30a20f7680d5385170a687c7", - "topLevelKeys": ["allowed", "applied_release_id", "contract_ops_authorization_id", "deployment_authorization_id", "deployment_plan_id"] + ".semapact/history/deployment_authorizations/2b383d08-7583-54dc-bd1a-2303a34ed38c.json": { + "checksum": "sha256:285a824c976aef723f1d77fcb406aefa13751511afc1be09f74cea8c90415130", + "topLevelKeys": [ + "allowed", + "authorization_kind", + "authorization_reference", + "authorization_version", + "deployment_authorization_id", + "deployment_plan_id", + "source_snapshot_id" + ] }, - ".semapact/history/deployment_authorizations/a1a210ef-25d0-5d00-a105-7e7907d63acb.json": { - "checksum": "sha256:915e95cdac4b9eac879221c1ffeef9ab95a8538ed0efbaf1c8f446afc77090cb", - "topLevelKeys": ["allowed", "applied_release_id", "contract_ops_authorization_id", "deployment_authorization_id", "deployment_plan_id"] + ".semapact/history/deployment_authorizations/e0c54b6a-f14a-5584-9134-9da2cd299ba3.json": { + "checksum": "sha256:a6d2770299e8676b38d467752ae32ac1b51d08add31bc0d5557ab60a4a193496", + "topLevelKeys": [ + "allowed", + "authorization_kind", + "authorization_reference", + "authorization_version", + "deployment_authorization_id", + "deployment_plan_id", + "source_snapshot_id" + ] }, - ".semapact/history/deployment_plans/c684e277-793b-5628-ae09-0b67316306a8.json": { - "checksum": "sha256:f6c254152dbfe16ff017f1cc43f40390e5dc59452df730e9701cb81868a98deb", - "topLevelKeys": ["actions", "applied_release_id", "contract_id", "deployment_plan_id", "plan_version", "release_plan_id", "released_revision_ref", "selected_version", "target"] + ".semapact/history/deployment_plans/71cabdc5-f6df-549f-810b-58546a767986.json": { + "checksum": "sha256:a42942b9107e36b3eb0bc57f67196f45f615dd636a122c33d005b05a6480c4a7", + "topLevelKeys": [ + "actions", + "contract_id", + "contract_version", + "deployment_plan_id", + "plan_version", + "revision_ref", + "source_snapshot_id", + "target" + ] }, - ".semapact/history/deployment_plans/fecc2ad3-b5fe-58a1-99a5-3fb30e28a6c3.json": { - "checksum": "sha256:446646a122bfc65c085a084f4bbb52d6f1ac9f6c94a1724061c2f19308c58f2c", - "topLevelKeys": ["actions", "applied_release_id", "contract_id", "deployment_plan_id", "plan_version", "release_plan_id", "released_revision_ref", "selected_version", "target"] + ".semapact/history/deployment_plans/bb2bddd8-ffc4-5cb2-bb50-5c963bc86772.json": { + "checksum": "sha256:423b6f8354fea81e1714366a79857b901ab423c713e6939952dcb05e757a11ca", + "topLevelKeys": [ + "actions", + "contract_id", + "contract_version", + "deployment_plan_id", + "plan_version", + "revision_ref", + "source_snapshot_id", + "target" + ] }, - ".semapact/history/deployment_previews/6eb94c8f-68ad-546a-8eb4-b1fb27ec21fa.json": { - "checksum": "sha256:8a42d5eeaacfed1c35fffd9da860bfb3b7ee6294d2887ac665ce0c6b2f7f500d", - "topLevelKeys": ["deployment_plan_id", "deployment_preview_id", "observation_fingerprint", "operations", "platform", "preview_version", "runtime_target", "source_identifier"] + ".semapact/history/deployment_previews/50f0e4ff-e23c-5b19-a823-8c87c8557766.json": { + "checksum": "sha256:5946711e28c3910a23dca2bda3b5f7fa76722da2c3ba4b838f75837b4ca0f926", + "topLevelKeys": [ + "deployment_plan_id", + "deployment_preview_id", + "observation_fingerprint", + "operations", + "platform", + "preview_version", + "runtime_target", + "source_identifier" + ] }, - ".semapact/history/deployment_previews/778b10a8-6133-572e-b89b-6d76be994154.json": { - "checksum": "sha256:ca3f88358aaea04096290f9cb3d6a4e39218a63aec986dd5b19f93ca8ecd9d9b", - "topLevelKeys": ["deployment_plan_id", "deployment_preview_id", "observation_fingerprint", "operations", "platform", "preview_version", "runtime_target", "source_identifier"] + ".semapact/history/deployment_previews/f3d22787-d634-50c7-b640-0e4deeb3a8bb.json": { + "checksum": "sha256:692825745edf96b9104d22a085ba0e369bbae48976f4e87b758365250c48aae4", + "topLevelKeys": [ + "deployment_plan_id", + "deployment_preview_id", + "observation_fingerprint", + "operations", + "platform", + "preview_version", + "runtime_target", + "source_identifier" + ] }, - ".semapact/history/deployment_records/61edafd1-bba0-5e2f-8fa1-f48ff7eeaafb.json": { - "checksum": "sha256:155b3dd66eca9cb519ccd3f5b3e0a6f3a4d4c430428d96ef0cddf5b6c3392aef", - "topLevelKeys": ["actor_reference", "completed_at", "deployment_authorization_id", "deployment_plan_id", "deployment_preview_id", "deployment_record_id", "external_reference", "platform", "release_record_id", "runtime_target", "source_reference", "started_at", "status"] + ".semapact/history/deployment_records/2f2c8752-0abf-5d19-b097-af057b4a0fe1.json": { + "checksum": "sha256:d88010da5c7fb72bf44fd9861a709ac13dd0339f64e2af8061057ddfc50c5b5f", + "topLevelKeys": [ + "actor_reference", + "completed_at", + "deployment_authorization_id", + "deployment_plan_id", + "deployment_preview_id", + "deployment_record_id", + "external_reference", + "platform", + "release_record_id", + "runtime_target", + "source_reference", + "started_at", + "status" + ] }, - ".semapact/history/deployment_records/c1cbc2d8-f340-5944-827a-c5bbc8d73835.json": { - "checksum": "sha256:17f423508bb8500f00f85c0ae4a9f9d1900e78c9cfff7a2b936e6e1f14357af1", - "topLevelKeys": ["actor_reference", "completed_at", "deployment_authorization_id", "deployment_plan_id", "deployment_preview_id", "deployment_record_id", "external_reference", "platform", "release_record_id", "runtime_target", "source_reference", "started_at", "status"] + ".semapact/history/deployment_records/7fe4b74d-604c-58ec-94ba-f469651ea25a.json": { + "checksum": "sha256:c2c856bcf5a0301525b91291effcee95fbfb7a65a12e71fe0cf786df94d0e873", + "topLevelKeys": [ + "actor_reference", + "completed_at", + "deployment_authorization_id", + "deployment_plan_id", + "deployment_preview_id", + "deployment_record_id", + "external_reference", + "platform", + "release_record_id", + "runtime_target", + "source_reference", + "started_at", + "status" + ] }, ".semapact/history/release_plans/45a429c1-9ff4-54da-897a-b02c7ee6027f.json": { "checksum": "sha256:e1f6e06acd06a1b0ff719ddce44430f293b03d65e5586607f13907d6b617b237", - "topLevelKeys": ["change_set_id", "contract_id", "decision_id", "preconditions", "release_plan_id", "release_revision_ref", "required_version_bump"] + "topLevelKeys": [ + "change_set_id", + "contract_id", + "decision_id", + "preconditions", + "release_plan_id", + "release_revision_ref", + "required_version_bump" + ] }, ".semapact/history/release_records/de7c4ed0-d54a-5a7a-b82e-c626eab39d23.json": { "checksum": "sha256:961815c36c9f21e9ca1a277bb256bdc6571bcd94187a2712f333fa2bf6834b9d", - "topLevelKeys": ["actual_version_bump", "applied_release_id", "authority_reference", "authorization_id", "change_set_id", "contract_id", "contract_version", "decision_id", "release_plan_id", "release_record_id", "released_revision_id", "required_version_bump", "review_evidence_action", "review_evidence_reference", "version_authority", "version_resolution_id"] + "topLevelKeys": [ + "actual_version_bump", + "applied_release_id", + "authority_reference", + "authorization_id", + "change_set_id", + "contract_id", + "contract_version", + "decision_id", + "release_plan_id", + "release_record_id", + "released_revision_id", + "required_version_bump", + "review_evidence_action", + "review_evidence_reference", + "version_authority", + "version_resolution_id" + ] }, ".semapact/history/runtime_observations/38695939-4fb5-583c-acfa-1a21397d89c8.json": { "checksum": "sha256:dcad511a463004815942e9404e2d6ad5edd7efeb95afa14926f2b08b2c0bc389", - "topLevelKeys": ["observation", "observation_record_id"] + "topLevelKeys": [ + "observation", + "observation_record_id" + ] }, ".semapact/history/runtime_observations/e259c151-972f-532a-99d1-a14dcd78b166.json": { "checksum": "sha256:d2af366ab491fdd0bfc123940f17f90257a456264343d017218c5791d2e1a6ad", - "topLevelKeys": ["observation", "observation_record_id"] + "topLevelKeys": [ + "observation", + "observation_record_id" + ] }, - ".semapact/history/runtime_reconciliations/1e53fecd-750f-556e-97a8-060dc05e3cc2.json": { - "checksum": "sha256:87f4076f1428aa835565ced57aedd8371ff4d01de144ce6c34d395b2bd6d1a97", - "topLevelKeys": ["deployment_record_id", "observation_record_id", "release_record_id", "result", "runtime_reconciliation_record_id", "status"] + ".semapact/history/runtime_reconciliations/1e5b9772-02f3-5461-9a7b-a8cf73f6a609.json": { + "checksum": "sha256:fd71cdab46b7a848934f5a7cb7e21cc9e254cf1b9929987d49f2f684b76a54fc", + "topLevelKeys": [ + "deployment_record_id", + "observation_record_id", + "release_record_id", + "result", + "runtime_reconciliation_record_id", + "status" + ] }, - ".semapact/history/runtime_reconciliations/eeaf42b7-6d15-5b80-b1c3-ce51da73293f.json": { - "checksum": "sha256:041909fb90b3c4ad24e093883d4dfed0c18bb445fbb17e9a6c5c1a47e6b6b42e", - "topLevelKeys": ["deployment_record_id", "observation_record_id", "release_record_id", "result", "runtime_reconciliation_record_id", "status"] + ".semapact/history/runtime_reconciliations/7d8fcefb-3470-590b-ba48-1f1f9910be85.json": { + "checksum": "sha256:35c959957502dbe4a34f161582a9fc720b6010977b6a80bdcb81de7beba9f629", + "topLevelKeys": [ + "deployment_record_id", + "observation_record_id", + "release_record_id", + "result", + "runtime_reconciliation_record_id", + "status" + ] } }, - "evolution": { - "contractId": "orders-product", - "proposals": [ - { - "changeSetId": "c2f4ee02-90e5-532a-ac8a-edd43e49f156", - "decisionId": "14295d1b-b52d-5d8b-8dad-2121529f099c", - "decision": "REVIEW", - "baseRevisionId": "fb437fde-4baa-5dad-a382-c4df381ba412", - "candidateRevisionId": "352d83a9-4ee6-508a-981f-4bc9819263c9", - "releases": [ - { - "releaseRecordId": "de7c4ed0-d54a-5a7a-b82e-c626eab39d23", - "contractVersion": "1.3.0", - "releasedRevisionId": "290bd707-017f-56ca-a379-c8879b9ede13", - "deployments": [ - { - "deploymentRecordId": "61edafd1-bba0-5e2f-8fa1-f48ff7eeaafb", - "runtimeTarget": "main.stage", - "status": "FAILED", - "runtime": [] - }, - { - "deploymentRecordId": "c1cbc2d8-f340-5944-827a-c5bbc8d73835", - "runtimeTarget": "main.prod", - "status": "SUCCEEDED", - "runtime": [ - { - "reconciliationRecordId": "1e53fecd-750f-556e-97a8-060dc05e3cc2", - "observationRecordId": "e259c151-972f-532a-99d1-a14dcd78b166", - "status": "IN_SYNC" - }, - { - "reconciliationRecordId": "eeaf42b7-6d15-5b80-b1c3-ce51da73293f", - "observationRecordId": "38695939-4fb5-583c-acfa-1a21397d89c8", - "status": "DRIFT" - } - ] - } - ] - } - ] - } - ], - "unlinkedReleaseIds": [], - "unlinkedRuntimeIds": [], - "brokenReferences": [] - } + "fixtureVersion": "history-v1" } From 38fe5b81683490bec357d164a545553c13838e88 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:07:55 +1000 Subject: [PATCH 344/616] refactor(deployment): keep legacy plan builder out of public exports --- semapact/deployment/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index b1fd5667..06bc2225 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -69,7 +69,6 @@ "validate_candidate_deployment_context", "validate_contract_release_deployment_context", "build_deployment_actions", - "build_deployment_plan", "build_deployment_plan_from_source", "build_candidate_deployment_source", "build_contract_release_deployment_source", From f3832048ed12f66e94253d81cc3ed607c9dfc804 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:07:58 +1000 Subject: [PATCH 345/616] style(deployment): consolidate compatibility imports --- semapact/deployment/compatibility.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/deployment/compatibility.py b/semapact/deployment/compatibility.py index 1f3743f2..8881befd 100644 --- a/semapact/deployment/compatibility.py +++ b/semapact/deployment/compatibility.py @@ -11,12 +11,12 @@ from typing import Any from semapact.contractops.execution_models import AppliedContractRelease, ReleaseSnapshot -from semapact.contractops.integrity import validate_contractops_authorization_identity -from semapact.contractops.models import AuthorizationReason, ContractOpsAuthorization from semapact.contractops.integrity import ( validate_applied_release_identity, + validate_contractops_authorization_identity, validate_release_snapshot_identity, ) +from semapact.contractops.models import AuthorizationReason, ContractOpsAuthorization from semapact.deployment.models import ( SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, From 3a908ee7cae562599769d03ee7bbc99a2e05a63a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:10:28 +1000 Subject: [PATCH 346/616] refactor(deployment): keep legacy authorization out of public exports From d0ca0b1e28e73a868a313664cdc7a150766e775d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 10:10:32 +1000 Subject: [PATCH 347/616] docs(deployment): describe canonical v5 provenance boundary --- docs/deployment_plans.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index e0423446..0bd55df0 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -55,7 +55,7 @@ The orchestration above is provider-neutral. Platform packages configure or impl A `DeploymentPlan` is a deterministic, provider-neutral statement of the runtime state that one exact deployment source intends to converge toward. -Canonical plans are v5 and bind only the immutable source snapshot plus optional release provenance. Candidate versus release mode is not stored as another boolean: it is derived from the source provenance. Legacy `AppliedContractRelease` / `ReleaseSnapshot` plan payloads remain readable behind compatibility seams. +Canonical plans are v5 and bind only the immutable `DeploymentSourceSnapshot` plus the target-specific convergence actions. Release provenance and candidate-versus-release mode live only in the source snapshot; they are not duplicated in `DeploymentPlan`. Legacy `AppliedContractRelease` / `ReleaseSnapshot` inputs and older plan payloads are verified and upgraded at the explicit compatibility boundary. The initial action vocabulary deliberately contains only: From 835c74cd4e4b423375eca33766937e17be483294 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:49:56 +1000 Subject: [PATCH 348/616] refactor(deployment): remove in-process deployment authorization model --- semapact/deployment/models.py | 78 ----------------------------------- 1 file changed, 78 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index 2c78d9d1..f6cd0f17 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -22,9 +22,6 @@ SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE = uuid.UUID( "d59eaa31-997a-478d-9978-4659beee673d" ) -SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE = uuid.UUID( - "c1dd6b40-cb67-44c1-b0f2-5f133ba6a3f5" -) SEMAPACT_DEPLOYMENT_PREVIEW_NAMESPACE = uuid.UUID( "0ee613b9-a9ef-4a95-ac87-25d6c706b16c" ) @@ -221,36 +218,6 @@ def _validate_identity(self) -> DeploymentPreview: return self -class DeploymentAuthorization(DeploymentModel): - """Canonical authorization bound to one exact DeploymentPlan source snapshot.""" - - deployment_authorization_id: str - deployment_plan_id: str - source_snapshot_id: str - allowed: bool = Field(strict=True) - authorization_kind: Literal["contractops", "governance"] = "contractops" - authorization_reference: str - authorization_version: Literal["2"] = "2" - - @field_validator( - "deployment_authorization_id", - "deployment_plan_id", - "source_snapshot_id", - "authorization_reference", - ) - @classmethod - def _require_authorization_text(cls, value: str) -> str: - cleaned = value.strip() - if not cleaned: - raise ValueError("value must not be empty") - return cleaned - - @model_validator(mode="after") - def _validate_identity(self) -> "DeploymentAuthorization": - validate_deployment_authorization_identity(self) - return self - - def compute_deployment_plan_id( *, source_snapshot_id: str, @@ -277,34 +244,6 @@ def compute_deployment_plan_id( ) -def compute_deployment_authorization_id( - *, - deployment_plan_id: str, - source_snapshot_id: str, - authorization_kind: str, - authorization_reference: str, - allowed: bool, - authorization_version: str = "2", -) -> str: - if authorization_version != "2": - raise ValueError( - f"Unsupported canonical DeploymentAuthorization version: {authorization_version}" - ) - if authorization_kind not in {"contractops", "governance"}: - raise ValueError("Unsupported deployment authorization kind") - return deterministic_uuid5( - SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, - { - "authorization_kind": authorization_kind, - "authorization_reference": authorization_reference, - "deployment_plan_id": deployment_plan_id, - "source_snapshot_id": source_snapshot_id, - "allowed": allowed, - "authorization_version": authorization_version, - }, - ) - - def compute_deployment_preview_id( *, deployment_plan_id: str, @@ -343,23 +282,6 @@ def validate_deployment_plan_identity(plan: DeploymentPlan) -> None: raise ValueError("DeploymentPlan deterministic identity does not match content") -def validate_deployment_authorization_identity( - authorization: DeploymentAuthorization, -) -> None: - expected = compute_deployment_authorization_id( - deployment_plan_id=authorization.deployment_plan_id, - source_snapshot_id=authorization.source_snapshot_id, - authorization_kind=authorization.authorization_kind, - authorization_reference=authorization.authorization_reference, - allowed=authorization.allowed, - authorization_version=authorization.authorization_version, - ) - if expected != authorization.deployment_authorization_id: - raise ValueError( - "DeploymentAuthorization deterministic identity does not match content" - ) - - def validate_deployment_preview_identity(preview: DeploymentPreview) -> None: expected = compute_deployment_preview_id( deployment_plan_id=preview.deployment_plan_id, From c47360a4fc3d024086688bd00a0b06b821734f07 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:50:03 +1000 Subject: [PATCH 349/616] refactor(deployment): remove legacy execute wrapper --- semapact/deployment/orchestrator.py | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/semapact/deployment/orchestrator.py b/semapact/deployment/orchestrator.py index ecf63932..78a3d060 100644 --- a/semapact/deployment/orchestrator.py +++ b/semapact/deployment/orchestrator.py @@ -9,21 +9,19 @@ from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, - DeploymentAuthorization, DeploymentPlan, DeploymentPreview, DeploymentTarget, NativeOperation, NativeOperationKind, compute_deployment_preview_id, - validate_deployment_authorization_identity, validate_deployment_plan_identity, validate_deployment_preview_identity, ) from semapact.deployment.providers import NativeOperationExecutor from semapact.deployment.schema_transitions import SchemaTransitionPlanner from semapact.deployment.verification import verify_deployment_convergence -from semapact.exceptions import ContractOpsAuthorizationError, ValidationError +from semapact.exceptions import ValidationError from semapact.observation.fingerprint import fingerprint_observed_state from semapact.observation.models import ObservedAssetIdentity, ObservedPlatformState from semapact.observation.providers import RuntimeAssetBinding, RuntimeProvider @@ -153,28 +151,6 @@ def apply( continue self._executor.execute(operation) - def execute( - self, - plan: DeploymentPlan, - preview: DeploymentPreview, - authorization: DeploymentAuthorization, - ) -> None: - """Compatibility wrapper for legacy in-process DeploymentAuthorization.""" - validate_deployment_authorization_identity(authorization) - if not authorization.allowed: - raise ContractOpsAuthorizationError( - "DeploymentAuthorization is not allowed" - ) - if authorization.deployment_plan_id != plan.deployment_plan_id: - raise ContractOpsAuthorizationError( - "DeploymentAuthorization is not bound to this DeploymentPlan" - ) - if authorization.source_snapshot_id != plan.source_snapshot_id: - raise ContractOpsAuthorizationError( - "DeploymentAuthorization source does not match DeploymentPlan" - ) - self.apply(plan, preview) - def _preview_from_observation( self, plan: DeploymentPlan, From 77058abb50a4a736dc928fdd7c800a65526d93fc Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:50:08 +1000 Subject: [PATCH 350/616] refactor(deployment): expose only plan preview apply verify --- semapact/application/services/deployment.py | 29 +-------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/semapact/application/services/deployment.py b/semapact/application/services/deployment.py index 8aeba817..5ca045f4 100644 --- a/semapact/application/services/deployment.py +++ b/semapact/application/services/deployment.py @@ -4,15 +4,13 @@ from semapact.deployment import ( DeploymentAdapter, - DeploymentAuthorization, DeploymentPlan, DeploymentPreview, DeploymentSourceSnapshot, DeploymentTarget, build_deployment_plan_from_source, ) -from semapact.deployment.models import validate_deployment_authorization_identity -from semapact.exceptions import ContractOpsAuthorizationError, ValidationError +from semapact.exceptions import ValidationError from semapact.reconciliation import ReconciliationResult @@ -46,31 +44,6 @@ def apply( _validate_component_key(adapter.key, plan.target.platform, "deployment adapter") adapter.apply(plan, preview) - def execute( - self, - plan: DeploymentPlan, - preview: DeploymentPreview, - authorization: DeploymentAuthorization, - *, - adapter: DeploymentAdapter, - ) -> None: - """Compatibility wrapper for legacy in-process authorization callers.""" - _validate_component_key(adapter.key, plan.target.platform, "deployment adapter") - validate_deployment_authorization_identity(authorization) - if not authorization.allowed: - raise ContractOpsAuthorizationError( - "DeploymentAuthorization is not allowed" - ) - if authorization.deployment_plan_id != plan.deployment_plan_id: - raise ContractOpsAuthorizationError( - "DeploymentAuthorization is not bound to this DeploymentPlan" - ) - if authorization.source_snapshot_id != plan.source_snapshot_id: - raise ContractOpsAuthorizationError( - "DeploymentAuthorization source does not match DeploymentPlan" - ) - adapter.apply(plan, preview) - def verify( self, plan: DeploymentPlan, From b4c7cf58b1785b804014ce9f17f0f5d8b654d655 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:50:11 +1000 Subject: [PATCH 351/616] refactor(deployment): remove candidate deployment authorization artifact --- semapact/deployment/authorization.py | 49 ---------------------------- 1 file changed, 49 deletions(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index ff465c85..0de97cf7 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -7,9 +7,7 @@ from __future__ import annotations from semapact.deployment.models import ( - DeploymentAuthorization, DeploymentPlan, - compute_deployment_authorization_id, validate_deployment_plan_identity, ) from semapact.deployment.source import DeploymentSourceSnapshot @@ -45,53 +43,6 @@ def validate_candidate_deployment_context( raise ReleaseValidationError("Candidate deployment is blocked by governance") -def authorize_candidate_deployment( - plan: DeploymentPlan, - source: DeploymentSourceSnapshot, - decision: GovernanceDecision, -) -> DeploymentAuthorization: - """Authorize a non-release deployment directly from governance outcome. - - Candidate deployment never creates release approval evidence. BLOCK remains - fail-closed; ALLOW and REVIEW may proceed as non-release runtime validation. - """ - if source.source_kind != "candidate": - raise ReleaseValidationError( - "Candidate deployment authorization requires non-release plan/source" - ) - validate_deployment_plan_identity(plan) - if plan.source_snapshot_id != source.source_snapshot_id: - raise ReleaseValidationError( - "DeploymentPlan does not reference the supplied candidate source" - ) - if plan.contract_id != source.contract_id: - raise ReleaseValidationError( - "DeploymentPlan and candidate source contract IDs do not match" - ) - if decision.contract_id != source.contract_id: - raise ReleaseValidationError( - "GovernanceDecision does not match candidate deployment contract" - ) - allowed = decision.decision is not DecisionResult.BLOCK - authorization_id = compute_deployment_authorization_id( - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=source.source_snapshot_id, - authorization_kind="governance", - authorization_reference=decision.decision_id, - allowed=allowed, - authorization_version="2", - ) - return DeploymentAuthorization( - deployment_authorization_id=authorization_id, - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=source.source_snapshot_id, - allowed=allowed, - authorization_kind="governance", - authorization_reference=decision.decision_id, - authorization_version="2", - ) - - def validate_contract_release_deployment_context( plan: DeploymentPlan, source: DeploymentSourceSnapshot, From 77fae080e07ce9a166bf6ec5f236ef7aeae96f84 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:50:14 +1000 Subject: [PATCH 352/616] refactor(deployment): remove legacy compatibility exports --- semapact/deployment/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index 06bc2225..4e1049db 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -10,19 +10,13 @@ RuntimeReleaseMetadataProjector, ) from semapact.deployment.authorization import ( - authorize_candidate_deployment, validate_candidate_deployment_context, validate_contract_release_deployment_context, ) -from semapact.deployment.compatibility import ( - authorize_legacy_deployment as authorize_deployment, - build_legacy_deployment_plan as build_deployment_plan, -) from semapact.deployment.compilers import TransitionCompiler from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, - DeploymentAuthorization, DeploymentPlan, DeploymentPreview, DeploymentTarget, From c0b9df0bd8767103e9831de4ccf32d9b6439a8aa Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:50:20 +1000 Subject: [PATCH 353/616] refactor(deployment): delete legacy compatibility layer --- semapact/deployment/compatibility.py | 415 --------------------------- 1 file changed, 415 deletions(-) delete mode 100644 semapact/deployment/compatibility.py diff --git a/semapact/deployment/compatibility.py b/semapact/deployment/compatibility.py deleted file mode 100644 index 8881befd..00000000 --- a/semapact/deployment/compatibility.py +++ /dev/null @@ -1,415 +0,0 @@ -"""Boundary adapters for legacy deployment artifacts. - -Legacy wire formats are verified with their historical deterministic identities and -then upgraded into canonical DeploymentPlan/DeploymentAuthorization objects. The -canonical domain models never persist legacy fields or versions. -""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -from semapact.contractops.execution_models import AppliedContractRelease, ReleaseSnapshot -from semapact.contractops.integrity import ( - validate_applied_release_identity, - validate_contractops_authorization_identity, - validate_release_snapshot_identity, -) -from semapact.contractops.models import AuthorizationReason, ContractOpsAuthorization -from semapact.deployment.models import ( - SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, - SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, - DeploymentAction, - DeploymentAuthorization, - DeploymentPlan, - DeploymentTarget, - compute_deployment_authorization_id, - compute_deployment_plan_id, -) -from semapact.deployment.planner import build_deployment_actions -from semapact.exceptions import ReleaseValidationError -from semapact.governance.gate import GovernanceOperation -from semapact.utils.deterministic import deterministic_uuid5 - - -def parse_deployment_plan_payload(payload: Mapping[str, Any]) -> DeploymentPlan: - """Verify and upgrade a legacy plan payload into canonical v5.""" - value = dict(payload) - version = _infer_plan_version(value) - - if version == "5" and not _has_legacy_plan_fields(value): - return DeploymentPlan.model_validate(value) - - target = DeploymentTarget.model_validate(value.get("target")) - actions = tuple( - DeploymentAction.model_validate(action) - for action in value.get("actions", ()) - ) - source_snapshot_id = _legacy_plan_source_id(value, version) - revision_ref = _legacy_plan_revision_ref(value) - contract_version = _legacy_plan_contract_version(value) - contract_id = _required_text(value.get("contract_id"), "contract_id") - - expected_legacy_id = _compute_legacy_deployment_plan_id( - version=version, - source_snapshot_id=source_snapshot_id, - contract_id=contract_id, - revision_ref=revision_ref, - contract_version=contract_version, - release_id=_optional_text(value.get("release_id")) - or (_optional_text(value.get("applied_release_id")) if version == "2" else None), - release_plan_id=_optional_text(value.get("release_plan_id")), - release=value.get("release"), - target=target, - actions=actions, - ) - if value.get("deployment_plan_id") != expected_legacy_id: - raise ValueError("Legacy DeploymentPlan deterministic identity does not match content") - - canonical_id = compute_deployment_plan_id( - source_snapshot_id=source_snapshot_id, - contract_id=contract_id, - revision_ref=revision_ref, - contract_version=contract_version, - target=target, - actions=actions, - ) - return DeploymentPlan( - deployment_plan_id=canonical_id, - source_snapshot_id=source_snapshot_id, - contract_id=contract_id, - revision_ref=revision_ref, - contract_version=contract_version, - target=target, - actions=actions, - ) - - -def serialize_deployment_plan_payload(plan: DeploymentPlan) -> dict[str, Any]: - """Persist only the canonical DeploymentPlan wire shape.""" - return plan.model_dump(mode="json") - - -def parse_deployment_authorization_payload( - payload: Mapping[str, Any], -) -> DeploymentAuthorization: - """Verify and upgrade a legacy authorization payload into canonical v2.""" - value = dict(payload) - if ( - "contract_ops_authorization_id" not in value - and "applied_release_id" not in value - and str(value.get("authorization_version", "2")) == "2" - ): - return DeploymentAuthorization.model_validate(value) - - deployment_plan_id = _required_text( - value.get("deployment_plan_id"), - "deployment_plan_id", - ) - source_snapshot_id = _required_text( - value.get("source_snapshot_id") or value.get("applied_release_id"), - "source_snapshot_id", - ) - authorization_reference = _required_text( - value.get("authorization_reference") - or value.get("contract_ops_authorization_id"), - "authorization_reference", - ) - authorization_kind = str(value.get("authorization_kind") or "contractops") - allowed = value.get("allowed") - if not isinstance(allowed, bool): - raise ValueError("allowed must be boolean") - - expected_legacy_id = deterministic_uuid5( - SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, - { - "contract_ops_authorization_id": authorization_reference, - "deployment_plan_id": deployment_plan_id, - "applied_release_id": source_snapshot_id, - "allowed": allowed, - }, - ) - if value.get("deployment_authorization_id") != expected_legacy_id: - raise ValueError( - "Legacy DeploymentAuthorization deterministic identity does not match content" - ) - - canonical_id = compute_deployment_authorization_id( - deployment_plan_id=deployment_plan_id, - source_snapshot_id=source_snapshot_id, - authorization_kind=authorization_kind, - authorization_reference=authorization_reference, - allowed=allowed, - ) - return DeploymentAuthorization( - deployment_authorization_id=canonical_id, - deployment_plan_id=deployment_plan_id, - source_snapshot_id=source_snapshot_id, - allowed=allowed, - authorization_kind=authorization_kind, - authorization_reference=authorization_reference, - ) - - -def serialize_deployment_authorization_payload( - authorization: DeploymentAuthorization, -) -> dict[str, Any]: - """Persist only the canonical DeploymentAuthorization wire shape.""" - return authorization.model_dump(mode="json") - - -def build_legacy_deployment_plan( - release: ReleaseSnapshot | AppliedContractRelease, - target: DeploymentTarget, -) -> DeploymentPlan: - """Adapt pre-ContractRelease release artifacts into one canonical plan. - - This is an explicit compatibility entrypoint. New workflows must plan from a - DeploymentSourceSnapshot instead. - """ - if isinstance(release, ReleaseSnapshot): - validate_release_snapshot_identity(release) - source_snapshot_id = release.release_snapshot_id - elif isinstance(release, AppliedContractRelease): - validate_applied_release_identity(release) - source_snapshot_id = release.applied_release_id - else: - raise TypeError( - "release must be ReleaseSnapshot or AppliedContractRelease, " - f"got {type(release).__name__}" - ) - if not isinstance(target, DeploymentTarget): - raise TypeError( - f"target must be DeploymentTarget, got {type(target).__name__}" - ) - - actions = build_deployment_actions(release.to_contract()) - plan_id = compute_deployment_plan_id( - source_snapshot_id=source_snapshot_id, - contract_id=release.contract_id, - revision_ref=release.release_revision_ref, - contract_version=release.selected_version, - target=target, - actions=actions, - ) - return DeploymentPlan( - deployment_plan_id=plan_id, - source_snapshot_id=source_snapshot_id, - contract_id=release.contract_id, - revision_ref=release.release_revision_ref, - contract_version=release.selected_version, - target=target, - actions=actions, - ) - - - -def authorize_legacy_deployment( - plan: DeploymentPlan, - release: ReleaseSnapshot | AppliedContractRelease, - authorization: ContractOpsAuthorization, -) -> DeploymentAuthorization: - """Adapt the legacy in-process DEPLOY authorization path to canonical v2.""" - if isinstance(release, ReleaseSnapshot): - validate_release_snapshot_identity(release) - release_id = release.release_snapshot_id - elif isinstance(release, AppliedContractRelease): - validate_applied_release_identity(release) - release_id = release.applied_release_id - else: - raise TypeError( - "release must be ReleaseSnapshot or AppliedContractRelease, " - f"got {type(release).__name__}" - ) - validate_contractops_authorization_identity(authorization) - - if authorization.operation is not GovernanceOperation.DEPLOY: - raise ReleaseValidationError( - "Deployment requires operation-scoped DEPLOY authorization" - ) - if plan.source_snapshot_id != release_id: - raise ReleaseValidationError( - "DeploymentPlan does not reference the supplied exact legacy release" - ) - if plan.contract_id != release.contract_id: - raise ReleaseValidationError( - "DeploymentPlan and legacy release contract IDs do not match" - ) - if plan.revision_ref != release.release_revision_ref: - raise ReleaseValidationError( - "DeploymentPlan revision does not match legacy release" - ) - if plan.contract_version != release.selected_version: - raise ReleaseValidationError( - "DeploymentPlan version does not match legacy release" - ) - if authorization.decision_id != release.decision_id: - raise ReleaseValidationError( - "DEPLOY authorization decision does not match legacy release" - ) - if authorization.change_set_id != release.change_set_id: - raise ReleaseValidationError( - "DEPLOY authorization ChangeSet does not match legacy release" - ) - if authorization.release_plan_id != release.release_plan_id: - raise ReleaseValidationError( - "DEPLOY authorization ReleasePlan does not match legacy release" - ) - if authorization.version_resolution_id != release.version_resolution_id: - raise ReleaseValidationError( - "DEPLOY authorization version resolution does not match legacy release" - ) - if ( - authorization.allowed - and authorization.reason is AuthorizationReason.ALLOWED_BY_REVIEW - and authorization.scope_reference != plan.deployment_plan_id - ): - raise ReleaseValidationError( - "Review-based DEPLOY authorization is not scoped to this DeploymentPlan" - ) - - authorization_id = compute_deployment_authorization_id( - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=plan.source_snapshot_id, - authorization_kind="contractops", - authorization_reference=authorization.authorization_id, - allowed=authorization.allowed, - ) - return DeploymentAuthorization( - deployment_authorization_id=authorization_id, - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=plan.source_snapshot_id, - allowed=authorization.allowed, - authorization_kind="contractops", - authorization_reference=authorization.authorization_id, - ) - -def _infer_plan_version(value: Mapping[str, Any]) -> str: - raw = value.get("plan_version") - if raw is not None: - version = str(raw) - elif "applied_release_id" in value: - version = "2" - elif ( - "release_id" in value - or "released_revision_ref" in value - or "selected_version" in value - ): - version = "3" - else: - version = "5" - if version not in {"2", "3", "4", "5"}: - raise ValueError(f"Unsupported legacy DeploymentPlan version: {version}") - return version - - -def _has_legacy_plan_fields(value: Mapping[str, Any]) -> bool: - return any( - field in value - for field in ( - "applied_release_id", - "release_id", - "release_plan_id", - "released_revision_ref", - "selected_version", - "release", - ) - ) - - -def _legacy_plan_source_id(value: Mapping[str, Any], version: str) -> str: - if version == "2": - raw = value.get("applied_release_id") or value.get("source_snapshot_id") - elif version == "3": - raw = value.get("release_id") or value.get("source_snapshot_id") - else: - raw = value.get("source_snapshot_id") - return _required_text(raw, "source_snapshot_id") - - -def _legacy_plan_revision_ref(value: Mapping[str, Any]) -> str: - return _required_text( - value.get("revision_ref") or value.get("released_revision_ref"), - "revision_ref", - ) - - -def _legacy_plan_contract_version(value: Mapping[str, Any]) -> str: - return _required_text( - value.get("contract_version") or value.get("selected_version"), - "contract_version", - ) - - -def _compute_legacy_deployment_plan_id( - *, - version: str, - source_snapshot_id: str, - contract_id: str, - revision_ref: str, - contract_version: str, - release_id: str | None, - release_plan_id: str | None, - release: object, - target: DeploymentTarget, - actions: tuple[DeploymentAction, ...], -) -> str: - if version in {"2", "3"}: - if release_id is None or release_plan_id is None: - raise ValueError( - f"Legacy DeploymentPlan v{version} requires release provenance" - ) - release_key = "applied_release_id" if version == "2" else "release_id" - return deterministic_uuid5( - SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, - { - release_key: release_id, - "contract_id": contract_id, - "release_plan_id": release_plan_id, - "released_revision_ref": revision_ref, - "selected_version": contract_version, - "target": target.model_dump(mode="json"), - "actions": [action.model_dump(mode="json") for action in actions], - "plan_version": version, - }, - ) - - has_release_id = release_id is not None - has_release_plan = release_plan_id is not None - if version == "4" and has_release_id != has_release_plan: - raise ValueError( - "Legacy DeploymentPlan release_id and release_plan_id must be provided together" - ) - if version == "4" and release is not None and bool(release) != has_release_id: - raise ValueError( - "Legacy DeploymentPlan release flag conflicts with release provenance" - ) - - legacy_payload = { - "source_snapshot_id": source_snapshot_id, - "contract_id": contract_id, - "revision_ref": revision_ref, - "contract_version": contract_version, - "release_id": release_id, - "release_plan_id": release_plan_id if version == "4" else None, - "target": target.model_dump(mode="json"), - "actions": [action.model_dump(mode="json") for action in actions], - "plan_version": version, - } - if version == "4": - legacy_payload["release"] = has_release_id - return deterministic_uuid5(SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, legacy_payload) - - -def _required_text(value: object, field_name: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{field_name} is required") - return value.strip() - - -def _optional_text(value: object) -> str | None: - if not isinstance(value, str): - return None - cleaned = value.strip() - return cleaned or None From 96485b45d5e2f56f801fdd83a9f51d8a3cc0dec9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:50:39 +1000 Subject: [PATCH 354/616] test(deployment): remove legacy authorization coverage --- tests/test_deployment_service.py | 87 +------------------------------- 1 file changed, 2 insertions(+), 85 deletions(-) diff --git a/tests/test_deployment_service.py b/tests/test_deployment_service.py index 11dd09a0..9a7a3e0a 100644 --- a/tests/test_deployment_service.py +++ b/tests/test_deployment_service.py @@ -12,25 +12,17 @@ DeploymentAction, DeploymentAdapter, DeploymentActionKind, - DeploymentAuthorization, DeploymentPlan, DeploymentPreview, DeploymentTarget, NativeOperation, NativeOperationKind, ) -from semapact.deployment.compatibility import ( - parse_deployment_authorization_payload, - serialize_deployment_authorization_payload, -) from semapact.deployment.models import ( - SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, - compute_deployment_authorization_id, compute_deployment_plan_id, compute_deployment_preview_id, ) from semapact.exceptions import ValidationError -from semapact.utils.deterministic import deterministic_uuid5 from semapact.observation import ObservedPlatformState, with_observed_state_fingerprint from semapact.reconciliation import ReconciliationResult from semapact.services.deployment_service import DeploymentService @@ -69,9 +61,6 @@ def apply(self, plan, preview) -> None: self.execute_calls += 1 self.executed = (plan, preview, None) - def execute(self, plan, preview, authorization) -> None: - self.execute_calls += 1 - self.executed = (plan, preview, authorization) def _plan() -> DeploymentPlan: @@ -174,55 +163,6 @@ def _adapter(plan: DeploymentPlan) -> FakeDeploymentAdapter: ) -def _authorization(plan: DeploymentPlan) -> DeploymentAuthorization: - authorization_id = compute_deployment_authorization_id( - authorization_kind="contractops", - authorization_reference="contractops-auth-1", - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=plan.source_snapshot_id, - allowed=True, - ) - return DeploymentAuthorization( - deployment_authorization_id=authorization_id, - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=plan.source_snapshot_id, - allowed=True, - authorization_kind="contractops", - authorization_reference="contractops-auth-1", - ) - - - - -def test_legacy_authorization_wire_format_is_explicitly_adapted() -> None: - plan = _plan() - legacy_id = deterministic_uuid5( - SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, - { - "contract_ops_authorization_id": "contractops-auth-1", - "deployment_plan_id": plan.deployment_plan_id, - "applied_release_id": plan.source_snapshot_id, - "allowed": True, - }, - ) - payload = { - "deployment_authorization_id": legacy_id, - "contract_ops_authorization_id": "contractops-auth-1", - "deployment_plan_id": plan.deployment_plan_id, - "applied_release_id": plan.source_snapshot_id, - "allowed": True, - } - - upgraded = parse_deployment_authorization_payload(payload) - - assert upgraded.authorization_version == "2" - assert upgraded.source_snapshot_id == plan.source_snapshot_id - assert upgraded.authorization_reference == "contractops-auth-1" - assert serialize_deployment_authorization_payload(upgraded) == upgraded.model_dump( - mode="json" - ) - assert upgraded.deployment_authorization_id != legacy_id - def test_preview_delegates_to_unified_adapter_entrypoint() -> None: plan = _plan() @@ -250,22 +190,6 @@ def test_apply_delegates_exact_plan_and_preview_without_internal_authorization() assert adapter.executed == (plan, preview, None) -def test_execute_compatibility_wrapper_validates_then_uses_canonical_apply() -> None: - plan = _plan() - preview = _preview(plan) - authorization = _authorization(plan) - adapter = _adapter(plan) - - DeploymentService().execute( - plan, - preview, - authorization, - adapter=adapter, - ) - - assert adapter.execute_calls == 1 - assert adapter.executed == (plan, preview, None) - def test_verify_delegates_to_same_adapter_entrypoint() -> None: plan = _plan() @@ -277,7 +201,7 @@ def test_verify_delegates_to_same_adapter_entrypoint() -> None: assert adapter.verify_calls == 1 -@pytest.mark.parametrize("operation", ["preview", "verify", "apply", "execute"]) +@pytest.mark.parametrize("operation", ["preview", "verify", "apply"]) def test_service_rejects_adapter_platform_mismatch(operation: str) -> None: plan = _plan() adapter = _adapter(plan) @@ -288,15 +212,8 @@ def test_service_rejects_adapter_platform_mismatch(operation: str) -> None: DeploymentService().preview(plan, adapter=adapter) elif operation == "verify": DeploymentService().verify(plan, adapter=adapter) - elif operation == "apply": - DeploymentService().apply(plan, _preview(plan), adapter=adapter) else: - DeploymentService().execute( - plan, - _preview(plan), - _authorization(plan), - adapter=adapter, - ) + DeploymentService().apply(plan, _preview(plan), adapter=adapter) assert adapter.preview_calls == 0 assert adapter.verify_calls == 0 From e11b86dad7e4071818f927e2155f2c06c3a21a7b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:50:42 +1000 Subject: [PATCH 355/616] test(deployment): exercise canonical apply boundary --- tests/test_deployment_orchestrator.py | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/tests/test_deployment_orchestrator.py b/tests/test_deployment_orchestrator.py index 0ceceb56..5532033e 100644 --- a/tests/test_deployment_orchestrator.py +++ b/tests/test_deployment_orchestrator.py @@ -10,12 +10,10 @@ from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, - DeploymentAuthorization, DeploymentPlan, DeploymentTarget, NativeOperation, NativeOperationKind, - compute_deployment_authorization_id, compute_deployment_plan_id, ) from semapact.deployment.orchestrator import DeploymentOrchestrator @@ -177,7 +175,7 @@ def _observation() -> ObservedPlatformState: -def test_generic_orchestrator_owns_observe_preview_freshness_and_execute() -> None: +def test_generic_orchestrator_owns_observe_preview_freshness_and_apply() -> None: plan = _plan() runtime_provider = _RuntimeProvider(_observation()) executor = _Executor() @@ -196,23 +194,7 @@ def test_generic_orchestrator_owns_observe_preview_freshness_and_execute() -> No assert preview.operations[0].kind is NativeOperationKind.CREATE assert preview.operations[0].statement == "CREATE_ASSET orders" - authorization_id = compute_deployment_authorization_id( - authorization_kind="contractops", - authorization_reference="contract-auth-1", - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=plan.source_snapshot_id, - allowed=True, - ) - authorization = DeploymentAuthorization( - deployment_authorization_id=authorization_id, - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=plan.source_snapshot_id, - allowed=True, - authorization_kind="contractops", - authorization_reference="contract-auth-1", - ) - - orchestrator.execute(plan, preview, authorization) + orchestrator.apply(plan, preview) assert runtime_provider.observe_calls == 2 assert executor.operations == [preview.operations[0]] From 79de581e108d0853f99c3891dabbdab407f1510f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:50:47 +1000 Subject: [PATCH 356/616] test(databricks): remove legacy deployment authorization path --- tests/test_deployment_databricks.py | 47 ++++++++--------------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/tests/test_deployment_databricks.py b/tests/test_deployment_databricks.py index 11d39cd4..b8d06ca4 100644 --- a/tests/test_deployment_databricks.py +++ b/tests/test_deployment_databricks.py @@ -11,16 +11,14 @@ from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, - DeploymentAuthorization, DeploymentPlan, DeploymentTarget, NativeOperation, NativeOperationKind, - compute_deployment_authorization_id, compute_deployment_plan_id, compute_deployment_preview_id, ) -from semapact.exceptions import ContractOpsAuthorizationError, ValidationError +from semapact.exceptions import ValidationError from semapact.observation.fingerprint import with_observed_state_fingerprint from semapact.observation.models import ( ObservedAsset, @@ -90,23 +88,6 @@ def _plan(*properties: SchemaProperty, source_reference: str = SOURCE_REFERENCE) ) -def _authorization(plan: DeploymentPlan, allowed: bool = True) -> DeploymentAuthorization: - authorization_id = compute_deployment_authorization_id( - authorization_kind="contractops", - authorization_reference="contractops-auth:test", - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=plan.source_snapshot_id, - allowed=allowed, - ) - return DeploymentAuthorization( - deployment_authorization_id=authorization_id, - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=plan.source_snapshot_id, - allowed=allowed, - authorization_kind="contractops", - authorization_reference="contractops-auth:test", - ) - def _state( *columns: tuple[str, str, bool], @@ -284,28 +265,26 @@ def test_preview_rejects_cross_source_runtime_evidence() -> None: adapter.preview(plan) -def test_execute_fails_closed_for_denied_stale_and_cross_source() -> None: +def test_apply_fails_closed_for_stale_and_cross_source() -> None: plan = _plan(_property("id", "BIGINT", required=True)) before = _state(("id", "bigint", False)) adapter, provider, _ = _adapter(before) preview = adapter.preview(plan) - with pytest.raises(ContractOpsAuthorizationError, match="not allowed"): - adapter.execute(plan, preview, _authorization(plan, False)) provider.state = _state( ("id", "bigint", False), ("other", "string", True), ) with pytest.raises(ValidationError, match="Runtime state changed"): - adapter.execute(plan, preview, _authorization(plan)) + adapter.apply(plan, preview) provider.state = before.model_copy(update={"source_identifier": "workspace-b"}) with pytest.raises(ValidationError, match="Runtime source changed"): - adapter.execute(plan, preview, _authorization(plan)) + adapter.apply(plan, preview) -def test_execute_rejects_tampered_plan_and_forged_native_command() -> None: +def test_apply_rejects_tampered_plan_and_forged_native_command() -> None: plan = _plan(_property("id", "BIGINT", required=True)) current = _state(("id", "bigint", False)) adapter, _, client = _adapter(current) @@ -313,7 +292,7 @@ def test_execute_rejects_tampered_plan_and_forged_native_command() -> None: tampered = plan.model_copy(update={"contract_version": "9.9.9"}) with pytest.raises(ValueError, match="DeploymentPlan deterministic identity"): - adapter.execute(tampered, preview, _authorization(plan)) + adapter.apply(tampered, preview) forged_operations = ( NativeOperation( @@ -334,11 +313,11 @@ def test_execute_rejects_tampered_plan_and_forged_native_command() -> None: update={"deployment_preview_id": forged_id, "operations": forged_operations} ) with pytest.raises(ValidationError, match="no longer equals"): - adapter.execute(plan, forged, _authorization(plan)) + adapter.apply(plan, forged) assert client.statement_execution.calls == [] -def test_execute_runs_exact_preview_statement() -> None: +def test_apply_runs_exact_preview_statement() -> None: plan = _plan( _property("id", "BIGINT", required=True), _property("note", "STRING"), @@ -347,7 +326,7 @@ def test_execute_runs_exact_preview_statement() -> None: adapter, _, client = _adapter(current) preview = adapter.preview(plan) - adapter.execute(plan, preview, _authorization(plan)) + adapter.apply(plan, preview) assert client.statement_execution.calls == [ "ALTER TABLE `main`.`silver`.`orders` ADD COLUMNS (`note` STRING)" @@ -395,18 +374,18 @@ def test_release_metadata_projection_requires_warehouse() -> None: ) -def test_no_op_execute_does_not_require_warehouse() -> None: +def test_no_op_apply_does_not_require_warehouse() -> None: plan = _plan(_property("id", "BIGINT", required=True)) current = _state(("id", "bigint", False)) adapter, _, client = _adapter(current, warehouse_id=None) preview = adapter.preview(plan) assert preview.operations[0].kind is NativeOperationKind.NO_OP - adapter.execute(plan, preview, _authorization(plan)) + adapter.apply(plan, preview) assert client.statement_execution.calls == [] -def test_mutation_execute_without_warehouse_fails_closed() -> None: +def test_mutation_apply_without_warehouse_fails_closed() -> None: plan = _plan( _property("id", "BIGINT", required=True), _property("note", "STRING"), @@ -416,7 +395,7 @@ def test_mutation_execute_without_warehouse_fails_closed() -> None: preview = adapter.preview(plan) with pytest.raises(ValidationError, match="warehouse_id"): - adapter.execute(plan, preview, _authorization(plan)) + adapter.apply(plan, preview) assert client.statement_execution.calls == [] From 9f6dff4ef573fbd5146c83704fc21d99610a0926 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:51:43 +1000 Subject: [PATCH 357/616] refactor(contractops): remove obsolete applied release artifacts --- semapact/contractops/execution_models.py | 69 ------------------------ 1 file changed, 69 deletions(-) diff --git a/semapact/contractops/execution_models.py b/semapact/contractops/execution_models.py index c22bcdf4..03d1199e 100644 --- a/semapact/contractops/execution_models.py +++ b/semapact/contractops/execution_models.py @@ -104,72 +104,3 @@ def to_contract(self) -> OpenDataContractStandard: return OpenDataContractStandard.model_validate_json( self.released_contract_json ) - - -class AppliedContractRelease(ContractOpsModel): - """Exact released ODCS state produced by an authorized APPLY operation.""" - - applied_release_id: str - contract_id: str - decision_id: str - change_set_id: str - release_plan_id: str - version_resolution_id: str - release_revision_ref: str - selected_version: str - authorization_id: str - released_contract_json: str - - @field_validator( - "applied_release_id", - "contract_id", - "decision_id", - "change_set_id", - "release_plan_id", - "version_resolution_id", - "release_revision_ref", - "selected_version", - "authorization_id", - "released_contract_json", - ) - @classmethod - def _require_non_empty_text(cls, value: str) -> str: - cleaned = value.strip() - if not cleaned: - raise ValueError("value must not be empty") - return cleaned - - @model_validator(mode="after") - def _validate_released_contract_snapshot(self) -> AppliedContractRelease: - contract = OpenDataContractStandard.model_validate_json(self.released_contract_json) - if str(contract.id or "") != self.contract_id: - raise ValueError("released contract ID does not match AppliedContractRelease") - if str(contract.version or "") != self.selected_version: - raise ValueError("released contract version does not match selected_version") - return self - - def to_contract(self) -> OpenDataContractStandard: - """Materialize a fresh mutable ODCS model from the immutable JSON snapshot.""" - return OpenDataContractStandard.model_validate_json(self.released_contract_json) - - -class PublicationResult(ContractOpsModel): - """Successful result of one explicitly authorized external publication.""" - - publication_id: str - applied_release_id: str - authorization_id: str - publication_reference: str - - @field_validator( - "publication_id", - "applied_release_id", - "authorization_id", - "publication_reference", - ) - @classmethod - def _require_publication_text(cls, value: str) -> str: - cleaned = value.strip() - if not cleaned: - raise ValueError("value must not be empty") - return cleaned From 857ffd134d0ea47b9f707c7f1e5039fda35d782c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:51:46 +1000 Subject: [PATCH 358/616] refactor(contractops): keep release snapshot construction canonical --- semapact/contractops/execution.py | 201 +----------------------------- 1 file changed, 4 insertions(+), 197 deletions(-) diff --git a/semapact/contractops/execution.py b/semapact/contractops/execution.py index 46ebda52..be325f48 100644 --- a/semapact/contractops/execution.py +++ b/semapact/contractops/execution.py @@ -1,45 +1,24 @@ -"""Explicit ContractOps APPLY and PUBLISH execution boundaries.""" +"""Pure ContractOps release snapshot construction.""" from __future__ import annotations -from typing import Protocol - from open_data_contract_standard.model import OpenDataContractStandard from semapact.contractops.context import validate_release_context -from semapact.contractops.execution_models import ( - AppliedContractRelease, - PublicationResult, - ReleaseSnapshot, -) -from semapact.contractops.integrity import ( - compute_applied_release_id, - compute_release_snapshot_id, - compute_publication_id, - validate_applied_release_identity, - validate_contractops_authorization_identity, -) +from semapact.contractops.execution_models import ReleaseSnapshot +from semapact.contractops.integrity import compute_release_snapshot_id from semapact.contractops.models import ( ChangeSet, ContractOpsAuthorization, ReleasePlan, VersionResolution, ) -from semapact.exceptions import ContractOpsAuthorizationError, ReleaseValidationError -from semapact.governance.gate import GovernanceOperation +from semapact.exceptions import ReleaseValidationError from semapact.governance.models import GovernanceDecision from semapact.odcs.serialization import canonical_contract_json from semapact.versioning import normalize_semver -class ContractReleasePublisher(Protocol): - """Narrow external publication port for one applied contract release.""" - - def publish(self, release: AppliedContractRelease) -> str: - """Publish the exact applied release and return an opaque reference.""" - ... - - def build_release_snapshot( candidate_contract: OpenDataContractStandard, *, @@ -115,175 +94,3 @@ def build_release_snapshot( selected_version=selected_version, released_contract_json=released_contract_json, ) - - -def apply_contract_release( - candidate_contract: OpenDataContractStandard, - *, - candidate_revision_ref: str, - decision: GovernanceDecision, - change_set: ChangeSet, - release_plan: ReleasePlan, - version_resolution: VersionResolution, - authorization: ContractOpsAuthorization, -) -> AppliedContractRelease: - """Compatibility APPLY boundary wrapping the pure ReleaseSnapshot.""" - _validate_authorization( - authorization, - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - operation=GovernanceOperation.APPLY, - ) - snapshot = build_release_snapshot( - candidate_contract, - candidate_revision_ref=candidate_revision_ref, - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - ) - - applied_release_id = compute_applied_release_id( - contract_id=snapshot.contract_id, - decision_id=snapshot.decision_id, - change_set_id=snapshot.change_set_id, - release_plan_id=snapshot.release_plan_id, - version_resolution_id=snapshot.version_resolution_id, - release_revision_ref=snapshot.release_revision_ref, - selected_version=snapshot.selected_version, - authorization_id=authorization.authorization_id, - released_contract_json=snapshot.released_contract_json, - ) - - return AppliedContractRelease( - applied_release_id=applied_release_id, - contract_id=snapshot.contract_id, - decision_id=snapshot.decision_id, - change_set_id=snapshot.change_set_id, - release_plan_id=snapshot.release_plan_id, - version_resolution_id=snapshot.version_resolution_id, - release_revision_ref=snapshot.release_revision_ref, - selected_version=snapshot.selected_version, - authorization_id=authorization.authorization_id, - released_contract_json=snapshot.released_contract_json, - ) - - -def publish_contract_release( - release: AppliedContractRelease, - *, - authorization: ContractOpsAuthorization, - publisher: ContractReleasePublisher, -) -> PublicationResult: - """Invoke one external publisher only after exact PUBLISH authorization.""" - if not isinstance(release, AppliedContractRelease): - raise TypeError( - f"release must be AppliedContractRelease, got {type(release).__name__}" - ) - if not isinstance(authorization, ContractOpsAuthorization): - raise TypeError( - "authorization must be ContractOpsAuthorization, " - f"got {type(authorization).__name__}" - ) - if not hasattr(publisher, "publish") or not callable(publisher.publish): - raise TypeError("publisher must provide a callable publish(release) method") - - validate_applied_release_identity(release) - _validate_publication_authorization(release, authorization) - - publication_reference = publisher.publish(release) - if not isinstance(publication_reference, str): - raise TypeError("publisher.publish() must return str") - publication_reference = publication_reference.strip() - if not publication_reference: - raise ReleaseValidationError( - "publisher.publish() returned an empty publication reference" - ) - - publication_id = compute_publication_id( - applied_release_id=release.applied_release_id, - authorization_id=authorization.authorization_id, - publication_reference=publication_reference, - ) - return PublicationResult( - publication_id=publication_id, - applied_release_id=release.applied_release_id, - authorization_id=authorization.authorization_id, - publication_reference=publication_reference, - ) - - -def _validate_authorization( - authorization: ContractOpsAuthorization, - *, - decision: GovernanceDecision, - change_set: ChangeSet, - release_plan: ReleasePlan, - version_resolution: VersionResolution, - operation: GovernanceOperation, -) -> None: - if not isinstance(authorization, ContractOpsAuthorization): - raise TypeError( - "authorization must be ContractOpsAuthorization, " - f"got {type(authorization).__name__}" - ) - validate_contractops_authorization_identity(authorization) - if authorization.operation is not operation: - raise ReleaseValidationError( - f"Authorization operation must be {operation.value}, " - f"got {authorization.operation.value}" - ) - if ( - authorization.decision_id != decision.decision_id - or authorization.change_set_id != change_set.change_set_id - or authorization.release_plan_id != release_plan.release_plan_id - or authorization.version_resolution_id - != version_resolution.version_resolution_id - ): - raise ReleaseValidationError( - "Authorization does not match the exact version-resolved release context" - ) - if not authorization.allowed: - raise ContractOpsAuthorizationError( - f"ContractOps {operation.value} is not authorized: " - f"{authorization.reason.value}" - ) - - -def _validate_publication_authorization( - release: AppliedContractRelease, - authorization: ContractOpsAuthorization, -) -> None: - validate_contractops_authorization_identity(authorization) - if authorization.operation is not GovernanceOperation.PUBLISH: - raise ReleaseValidationError( - "Publication requires operation-scoped PUBLISH authorization" - ) - if ( - authorization.decision_id != release.decision_id - or authorization.change_set_id != release.change_set_id - or authorization.release_plan_id != release.release_plan_id - or authorization.version_resolution_id != release.version_resolution_id - ): - raise ReleaseValidationError( - "PUBLISH authorization does not match the applied release context" - ) - if not authorization.allowed: - raise ContractOpsAuthorizationError( - "ContractOps PUBLISH is not authorized: " - f"{authorization.reason.value}" - ) - - -def _canonical_version(version: str, *, field_name: str) -> str: - try: - canonical = normalize_semver(version) - except ValueError as exc: - raise ReleaseValidationError(f"{field_name} is not valid semantic version") from exc - if canonical != str(version).strip(): - raise ReleaseValidationError( - f"{field_name} must use canonical major.minor.patch form" - ) - return canonical From 56901d1df7aeb875c7c677b935330276c8eaecdc Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:51:49 +1000 Subject: [PATCH 359/616] refactor(contractops): export only canonical release artifacts --- semapact/contractops/__init__.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/semapact/contractops/__init__.py b/semapact/contractops/__init__.py index 49163ed7..8e04024a 100644 --- a/semapact/contractops/__init__.py +++ b/semapact/contractops/__init__.py @@ -5,25 +5,16 @@ build_change_set, build_change_set_from_decision, ) -from semapact.contractops.execution import ( - ContractReleasePublisher, - apply_contract_release, - build_release_snapshot, - publish_contract_release, -) +from semapact.contractops.execution import build_release_snapshot from semapact.contractops.execution_models import ( - AppliedContractRelease, ContractRelease, - PublicationResult, ReleaseSnapshot, ) from semapact.contractops.integrity import ( - validate_applied_release_identity, validate_contract_release_identity, validate_release_snapshot_identity, validate_change_set_identity, validate_contractops_authorization_identity, - validate_publication_result_identity, validate_release_plan_identity, validate_version_resolution_identity, ) @@ -46,14 +37,11 @@ ) __all__ = [ - "AppliedContractRelease", "ContractRelease", "ReleaseSnapshot", "AuthorizationReason", "ChangeSet", "ContractOpsAuthorization", - "ContractReleasePublisher", - "PublicationResult", "ReleasePlan", "ReleasePrecondition", "ReviewAuthorizationEvidence", @@ -61,21 +49,17 @@ "VersionAuthority", "VersionAuthorityConfig", "VersionResolution", - "apply_contract_release", "build_release_snapshot", "authorize_contract_operation", "build_change_set", "build_change_set_from_decision", "build_release_plan", "extract_version_from_release_reference", - "publish_contract_release", "resolve_release_version", - "validate_applied_release_identity", "validate_contract_release_identity", "validate_release_snapshot_identity", "validate_change_set_identity", "validate_contractops_authorization_identity", - "validate_publication_result_identity", "validate_release_plan_identity", "validate_version_resolution_identity", ] From 1c33c19ef81bdf71688b0cb20d7f2e8335989ec9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:51:52 +1000 Subject: [PATCH 360/616] refactor(contractops): remove legacy deterministic identities --- semapact/contractops/integrity.py | 84 +------------------------------ 1 file changed, 1 insertion(+), 83 deletions(-) diff --git a/semapact/contractops/integrity.py b/semapact/contractops/integrity.py index ba302fd6..a9e2f3f5 100644 --- a/semapact/contractops/integrity.py +++ b/semapact/contractops/integrity.py @@ -14,9 +14,7 @@ from semapact.change_context import ChangeContext from semapact.contractops.execution_models import ( - AppliedContractRelease, ContractRelease, - PublicationResult, ReleaseSnapshot, ) from semapact.contractops.models import ( @@ -45,15 +43,9 @@ SEMAPACT_RELEASE_SNAPSHOT_NAMESPACE = uuid.UUID( "d4248396-5ec7-4e6a-a238-b2db1abf76e8" ) -SEMAPACT_APPLIED_RELEASE_NAMESPACE = uuid.UUID( - "a5de2e65-aee7-48ac-9cb6-6a785f4cdf33" -) SEMAPACT_CONTRACT_RELEASE_NAMESPACE = uuid.UUID( "0f95c8c5-4957-43f7-a38c-2756605c2df6" ) -SEMAPACT_PUBLICATION_NAMESPACE = uuid.UUID( - "f776fc77-b37f-43ef-bf6d-d8dcf38d264f" -) def validate_contractops_artifact_identity(artifact: object) -> None: @@ -73,12 +65,8 @@ def validate_contractops_artifact_identity(artifact: object) -> None: validate_contractops_authorization_identity(artifact) elif isinstance(artifact, ReleaseSnapshot): validate_release_snapshot_identity(artifact) - elif isinstance(artifact, AppliedContractRelease): - validate_applied_release_identity(artifact) elif isinstance(artifact, ContractRelease): validate_contract_release_identity(artifact) - elif isinstance(artifact, PublicationResult): - validate_publication_result_identity(artifact) def compute_change_set_id( @@ -217,10 +205,7 @@ def compute_contractops_authorization_id( "evidence_reference": evidence_reference, "evidence_action": evidence_action, } - # Compatibility invariant: scope_reference did not participate in pre-#122 IDs - # when it was absent. Keep that byte-for-byte behavior. - if scope_reference is not None: - payload["scope_reference"] = scope_reference + payload["scope_reference"] = scope_reference return deterministic_uuid5(SEMAPACT_CONTRACTOPS_AUTHORIZATION_NAMESPACE, payload) @@ -289,48 +274,6 @@ def validate_release_snapshot_identity(snapshot: ReleaseSnapshot) -> None: _require_identity(snapshot.release_snapshot_id, expected, "ReleaseSnapshot") -def compute_applied_release_id( - *, - contract_id: str, - decision_id: str, - change_set_id: str, - release_plan_id: str, - version_resolution_id: str, - release_revision_ref: str, - selected_version: str, - authorization_id: str, - released_contract_json: str, -) -> str: - payload = { - "contract_id": contract_id, - "decision_id": decision_id, - "change_set_id": change_set_id, - "release_plan_id": release_plan_id, - "version_resolution_id": version_resolution_id, - "release_revision_ref": release_revision_ref, - "selected_version": selected_version, - "authorization_id": authorization_id, - "released_contract_json": released_contract_json, - } - return deterministic_uuid5(SEMAPACT_APPLIED_RELEASE_NAMESPACE, payload) - - -def validate_applied_release_identity(release: AppliedContractRelease) -> None: - _require_canonical_json(release.released_contract_json, "AppliedContractRelease snapshot") - expected = compute_applied_release_id( - contract_id=release.contract_id, - decision_id=release.decision_id, - change_set_id=release.change_set_id, - release_plan_id=release.release_plan_id, - version_resolution_id=release.version_resolution_id, - release_revision_ref=release.release_revision_ref, - selected_version=release.selected_version, - authorization_id=release.authorization_id, - released_contract_json=release.released_contract_json, - ) - _require_identity(release.applied_release_id, expected, "AppliedContractRelease") - - def compute_contract_release_id( *, contract_id: str, @@ -380,31 +323,6 @@ def validate_contract_release_identity(release: ContractRelease) -> None: _require_identity(release.contract_release_id, expected, "ContractRelease") -def compute_publication_id( - *, - applied_release_id: str, - authorization_id: str, - publication_reference: str, -) -> str: - return deterministic_uuid5( - SEMAPACT_PUBLICATION_NAMESPACE, - { - "applied_release_id": applied_release_id, - "authorization_id": authorization_id, - "publication_reference": publication_reference, - }, - ) - - -def validate_publication_result_identity(result: PublicationResult) -> None: - expected = compute_publication_id( - applied_release_id=result.applied_release_id, - authorization_id=result.authorization_id, - publication_reference=result.publication_reference, - ) - _require_identity(result.publication_id, expected, "PublicationResult") - - def _require_identity(actual: str, expected: str, artifact: str) -> None: if actual != expected: raise ReleaseValidationError( From d047c22b44d50c6b7381f8d655e170a7e6759a5a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:52:11 +1000 Subject: [PATCH 361/616] test(deployment): keep plan tests canonical only --- tests/test_deployment_plan.py | 205 +++++++++++++--------------------- 1 file changed, 79 insertions(+), 126 deletions(-) diff --git a/tests/test_deployment_plan.py b/tests/test_deployment_plan.py index e9f75582..94d744ea 100644 --- a/tests/test_deployment_plan.py +++ b/tests/test_deployment_plan.py @@ -10,24 +10,18 @@ ) from pydantic import ValidationError as PydanticValidationError -from semapact.contractops import AppliedContractRelease, ReleaseSnapshot -from semapact.contractops.integrity import ( - compute_applied_release_id, - compute_release_snapshot_id, -) -from semapact.deployment.compatibility import ( - build_legacy_deployment_plan, - parse_deployment_plan_payload, - serialize_deployment_plan_payload, -) -from semapact.deployment.models import ( - SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, +from semapact.contractops import ContractRelease +from semapact.contractops.integrity import compute_contract_release_id +from semapact.deployment import ( DeploymentAction, DeploymentActionKind, DeploymentPlan, DeploymentTarget, + build_candidate_deployment_source, + build_contract_release_deployment_source, + build_deployment_plan_from_source, ) -from semapact.utils.deterministic import deterministic_uuid5 +from semapact.odcs.serialization import canonical_contract_json def _schema( @@ -49,59 +43,41 @@ def _schema( ) -def _release( +def _contract( *, schemas: list[SchemaObject] | None = None, -) -> AppliedContractRelease: - contract = OpenDataContractStandard( + version: str = "1.2.0", +) -> OpenDataContractStandard: + return OpenDataContractStandard( apiVersion="v3.1.0", kind="DataContract", id="orders-product", name="Orders", - version="1.2.0", + version=version, status="active", schema=schemas or [_schema("orders")], ) - released_contract_json = json.dumps( - contract.model_dump(mode="json", by_alias=True, exclude_none=True), - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - ) + + +def _release( + *, + schemas: list[SchemaObject] | None = None, +) -> ContractRelease: + contract = _contract(schemas=schemas) + released_contract_json = canonical_contract_json(contract) fields = { "contract_id": "orders-product", + "contract_version": "1.2.0", "decision_id": "decision:test", "change_set_id": "change-set:test", "release_plan_id": "release-plan:test", "version_resolution_id": "version-resolution:test", - "release_revision_ref": "rev:released", - "selected_version": "1.2.0", - "authorization_id": "authorization:test", + "release_snapshot_id": "release-snapshot:test", + "source_revision_ref": "rev:released", "released_contract_json": released_contract_json, } - return AppliedContractRelease( - applied_release_id=compute_applied_release_id(**fields), - **fields, - ) - - -def _snapshot( - *, - schemas: list[SchemaObject] | None = None, -) -> ReleaseSnapshot: - applied = _release(schemas=schemas) - fields = { - "contract_id": applied.contract_id, - "decision_id": applied.decision_id, - "change_set_id": applied.change_set_id, - "release_plan_id": applied.release_plan_id, - "version_resolution_id": applied.version_resolution_id, - "release_revision_ref": applied.release_revision_ref, - "selected_version": applied.selected_version, - "released_contract_json": applied.released_contract_json, - } - return ReleaseSnapshot( - release_snapshot_id=compute_release_snapshot_id(**fields), + return ContractRelease( + contract_release_id=compute_contract_release_id(**fields), **fields, ) @@ -115,88 +91,53 @@ def _target() -> DeploymentTarget: ) -def test_legacy_release_snapshot_is_adapted_to_canonical_v5_plan() -> None: - snapshot = _snapshot() +def test_candidate_source_produces_canonical_v5_plan() -> None: + source = build_candidate_deployment_source( + _contract(), + revision_ref="rev:candidate", + ) - plan = build_legacy_deployment_plan(snapshot, _target()) + plan = build_deployment_plan_from_source(source, _target()) assert plan.plan_version == "5" - assert plan.source_snapshot_id == snapshot.release_snapshot_id - assert plan.contract_id == snapshot.contract_id - assert plan.contract_version == snapshot.selected_version - payload = serialize_deployment_plan_payload(plan) - assert payload["plan_version"] == "5" - assert "release_id" not in payload - assert "release_plan_id" not in payload - assert "applied_release_id" not in payload - assert "selected_version" not in payload + assert plan.source_snapshot_id == source.source_snapshot_id + assert plan.contract_id == source.contract_id + assert plan.contract_version == source.contract_version + assert not hasattr(plan, "release_id") + assert not hasattr(plan, "release_plan_id") -def test_same_exact_legacy_release_and_target_produce_same_canonical_plan() -> None: - release = _release(schemas=[_schema("zeta"), _schema("alpha")]) +def test_finalized_release_source_produces_canonical_v5_plan() -> None: + release = _release() + source = build_contract_release_deployment_source(release) - first = build_legacy_deployment_plan(release, _target()) - second = build_legacy_deployment_plan(release, _target()) + plan = build_deployment_plan_from_source(source, _target()) - assert first == second - assert first.deployment_plan_id == second.deployment_plan_id - assert first.model_dump(mode="json") == second.model_dump(mode="json") - assert [action.governed_asset for action in first.actions] == ["alpha", "zeta"] + assert plan.plan_version == "5" + assert source.release_id == release.contract_release_id + assert plan.source_snapshot_id == source.source_snapshot_id + assert plan.contract_version == release.contract_version -def test_legacy_v2_wire_payload_upgrades_to_canonical_identity() -> None: - release = _release() - canonical = build_legacy_deployment_plan(release, _target()) - legacy_payload = { - "applied_release_id": release.applied_release_id, - "contract_id": release.contract_id, - "release_plan_id": release.release_plan_id, - "released_revision_ref": release.release_revision_ref, - "selected_version": release.selected_version, - "target": canonical.target.model_dump(mode="json"), - "actions": [action.model_dump(mode="json") for action in canonical.actions], - "plan_version": "2", - } - legacy_payload["deployment_plan_id"] = deterministic_uuid5( - SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, - { - **legacy_payload, - "target": canonical.target.model_dump(mode="json"), - "actions": [action.model_dump(mode="json") for action in canonical.actions], - }, +def test_same_exact_source_and_target_produce_same_plan() -> None: + source = build_contract_release_deployment_source( + _release(schemas=[_schema("zeta"), _schema("alpha")]) ) - upgraded = parse_deployment_plan_payload(legacy_payload) - - assert upgraded == canonical - assert upgraded.plan_version == "5" - assert upgraded.deployment_plan_id != legacy_payload["deployment_plan_id"] - - -def test_legacy_v2_wire_payload_tamper_fails_closed() -> None: - release = _release() - canonical = build_legacy_deployment_plan(release, _target()) - legacy_payload = { - "deployment_plan_id": "legacy:stale", - "applied_release_id": release.applied_release_id, - "contract_id": release.contract_id, - "release_plan_id": release.release_plan_id, - "released_revision_ref": release.release_revision_ref, - "selected_version": release.selected_version, - "target": canonical.target.model_dump(mode="json"), - "actions": [action.model_dump(mode="json") for action in canonical.actions], - "plan_version": "2", - } + first = build_deployment_plan_from_source(source, _target()) + second = build_deployment_plan_from_source(source, _target()) - with pytest.raises(ValueError, match="Legacy DeploymentPlan deterministic identity"): - parse_deployment_plan_payload(legacy_payload) + assert first == second + assert first.deployment_plan_id == second.deployment_plan_id + assert [action.governed_asset for action in first.actions] == ["alpha", "zeta"] def test_actions_are_provider_neutral_ensure_state_intents() -> None: - plan = build_legacy_deployment_plan( - _release(schemas=[_schema("orders"), _schema("customers")]), - _target(), + source = build_candidate_deployment_source( + _contract(schemas=[_schema("orders"), _schema("customers")]), + revision_ref="rev:candidate", ) + plan = build_deployment_plan_from_source(source, _target()) assert plan.actions assert all( @@ -206,22 +147,33 @@ def test_actions_are_provider_neutral_ensure_state_intents() -> None: def test_physical_name_is_binding_hint_not_governed_identity() -> None: - plan = build_legacy_deployment_plan( - _release(schemas=[_schema("Orders", physical_name="prod_orders_v2")]), - _target(), + source = build_candidate_deployment_source( + _contract(schemas=[_schema("Orders", physical_name="prod_orders_v2")]), + revision_ref="rev:candidate", ) + plan = build_deployment_plan_from_source(source, _target()) action = plan.actions[0] assert action.governed_asset == "orders" assert action.physical_name == "prod_orders_v2" -def test_target_changes_canonical_plan_identity() -> None: - release = _release() +def test_missing_physical_name_falls_back_to_governed_schema_name() -> None: + source = build_candidate_deployment_source( + _contract(schemas=[_schema("Orders")]), + revision_ref="rev:candidate", + ) + plan = build_deployment_plan_from_source(source, _target()) + + assert plan.actions[0].physical_name == "Orders" + + +def test_target_changes_plan_identity() -> None: + source = build_contract_release_deployment_source(_release()) - production = build_legacy_deployment_plan(release, _target()) - staging = build_legacy_deployment_plan( - release, + production = build_deployment_plan_from_source(source, _target()) + staging = build_deployment_plan_from_source( + source, DeploymentTarget( platform="databricks", runtime_target="main.staging", @@ -250,10 +202,11 @@ def test_desired_state_identity_mismatch_fails_closed() -> None: ) -def test_canonical_deployment_plan_rejects_legacy_fields() -> None: - plan = build_legacy_deployment_plan(_release(), _target()) +def test_canonical_deployment_plan_rejects_release_provenance_fields() -> None: + source = build_contract_release_deployment_source(_release()) + plan = build_deployment_plan_from_source(source, _target()) payload = plan.model_dump(mode="json") - payload["release_id"] = "legacy-release" + payload["release_id"] = "unexpected" with pytest.raises(PydanticValidationError): DeploymentPlan.model_validate(payload) From ec61b484bc02cc102b146106862cb5aabcc1199c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:52:32 +1000 Subject: [PATCH 362/616] test(databricks): use canonical deployment path only --- tests/test_databricks_convergence_e2e.py | 154 ++++------------------- 1 file changed, 25 insertions(+), 129 deletions(-) diff --git a/tests/test_databricks_convergence_e2e.py b/tests/test_databricks_convergence_e2e.py index db34407d..80b3bf8c 100644 --- a/tests/test_databricks_convergence_e2e.py +++ b/tests/test_databricks_convergence_e2e.py @@ -17,30 +17,14 @@ from semapact.application.services.deployment import DeploymentService from semapact.application.services.deployment_workflow import DeploymentWorkflowService from semapact.change_context import ChangeContext -from semapact.contractops import ( - apply_contract_release, - authorize_contract_operation, - build_change_set_from_decision, - build_release_plan, - resolve_release_version, - VersionAuthorityConfig, -) from semapact.deployment import ( DeploymentPlan, DeploymentPreview, DeploymentTarget, + build_candidate_deployment_source, ) -from semapact.deployment.compatibility import ( - authorize_legacy_deployment, - build_legacy_deployment_plan, -) -from semapact.deployment.models import ( - NativeOperationKind, - compute_deployment_authorization_id, -) -from semapact.exceptions import ContractOpsAuthorizationError, ValidationError -from semapact.governance import DecisionResult, evaluate_governance_decision -from semapact.governance.gate import GovernanceOperation +from semapact.deployment.models import NativeOperationKind +from semapact.exceptions import ValidationError from semapact.interfaces.commands import deployment_cmd from semapact.observation.fingerprint import with_observed_state_fingerprint from semapact.observation.models import ( @@ -99,53 +83,6 @@ def _contract( ) -def _release_context(*properties: SchemaProperty): - base = _contract(*properties, name="orders-old") - candidate = _contract(*properties, name="orders-new") - decision = evaluate_governance_decision(base, candidate, context=_CONTEXT) - assert decision.decision is DecisionResult.ALLOW - - change_set = build_change_set_from_decision( - decision, - base_revision_ref="git:base", - candidate_revision_ref="git:candidate", - source="databricks-convergence-golden", - actor_reference="service:ci", - ) - release_plan = build_release_plan(change_set, decision) - version_resolution = resolve_release_version( - release_plan, - current_version="1.0.0", - config=VersionAuthorityConfig(), - ) - apply_authorization = authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - ) - release = apply_contract_release( - candidate, - candidate_revision_ref="git:candidate", - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=apply_authorization, - ) - deploy_authorization = authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.DEPLOY, - ) - return SimpleNamespace( - release=release, - deploy_authorization=deploy_authorization, - ) - def _target(*, source_reference: str = _SOURCE) -> DeploymentTarget: return DeploymentTarget( @@ -320,20 +257,17 @@ def _workflow( workspace: _StatefulWorkspace, *properties: SchemaProperty, ): - release_context = _release_context(*properties) - service = DeploymentService() - plan = build_legacy_deployment_plan(release_context.release, _target()) - authorization = authorize_legacy_deployment( - plan, - release_context.release, - release_context.deploy_authorization, + contract = _contract(*properties, name="orders") + source = build_candidate_deployment_source( + contract, + revision_ref="git:candidate", ) + service = DeploymentService() + plan = service.plan(source, _target()) return SimpleNamespace( service=service, plan=plan, - authorization=authorization, adapter=_adapter(workspace), - release_context=release_context, ) @@ -417,7 +351,7 @@ def test_bundle_cd_replans_against_runtime_changed_after_ci() -> None: assert workspace.statements == [] -def test_missing_table_create_execute_and_fresh_verify_converge() -> None: +def test_missing_table_create_apply_and_fresh_verify_converge() -> None: workspace = _StatefulWorkspace(present=False) workflow = _workflow( workspace, @@ -430,10 +364,9 @@ def test_missing_table_create_execute_and_fresh_verify_converge() -> None: ) assert preview.operations[0].kind is NativeOperationKind.CREATE - workflow.service.execute( + workflow.service.apply( workflow.plan, preview, - workflow.authorization, adapter=workflow.adapter, ) @@ -445,7 +378,7 @@ def test_missing_table_create_execute_and_fresh_verify_converge() -> None: _assert_in_sync(workflow) -def test_missing_nullable_column_alter_execute_and_fresh_verify_converge() -> None: +def test_missing_nullable_column_alter_apply_and_fresh_verify_converge() -> None: workspace = _StatefulWorkspace( present=True, columns=(("id", "INT", False),), @@ -462,10 +395,9 @@ def test_missing_nullable_column_alter_execute_and_fresh_verify_converge() -> No ) assert preview.operations[0].kind is NativeOperationKind.ALTER - workflow.service.execute( + workflow.service.apply( workflow.plan, preview, - workflow.authorization, adapter=workflow.adapter, ) @@ -480,7 +412,7 @@ def test_missing_nullable_column_alter_execute_and_fresh_verify_converge() -> No _assert_in_sync(workflow) -def test_compliant_table_no_op_execute_and_verify_converge() -> None: +def test_compliant_table_no_op_apply_and_verify_converge() -> None: workspace = _StatefulWorkspace( present=True, columns=(("id", "INT", False),), @@ -496,10 +428,9 @@ def test_compliant_table_no_op_execute_and_verify_converge() -> None: ) assert preview.operations[0].kind is NativeOperationKind.NO_OP - workflow.service.execute( + workflow.service.apply( workflow.plan, preview, - workflow.authorization, adapter=workflow.adapter, ) @@ -507,7 +438,7 @@ def test_compliant_table_no_op_execute_and_verify_converge() -> None: _assert_in_sync(workflow) -def test_stale_runtime_between_preview_and_execute_fails_closed() -> None: +def test_stale_runtime_between_preview_and_apply_fails_closed() -> None: workspace = _StatefulWorkspace(present=False) workflow = _workflow( workspace, @@ -522,12 +453,11 @@ def test_stale_runtime_between_preview_and_execute_fails_closed() -> None: workspace.columns = [("id", "INT", False)] with pytest.raises(ValidationError, match="Runtime state changed"): - workflow.service.execute( - workflow.plan, - preview, - workflow.authorization, - adapter=workflow.adapter, - ) + workflow.service.apply( + workflow.plan, + preview, + adapter=workflow.adapter, + ) assert workspace.statements == [] @@ -553,7 +483,7 @@ def test_external_asset_requiring_mutation_fails_closed() -> None: assert workspace.statements == [] -def test_wrong_workspace_source_fails_preview_and_execute_closed() -> None: +def test_wrong_workspace_source_fails_preview_and_apply_closed() -> None: wrong_workspace = _StatefulWorkspace( present=False, source_identifier="workspace:other", @@ -581,50 +511,16 @@ def test_wrong_workspace_source_fails_preview_and_execute_closed() -> None: workspace.source_identifier = "workspace:other" with pytest.raises(ValidationError, match="Runtime source changed"): - workflow.service.execute( - workflow.plan, - preview, - workflow.authorization, - adapter=workflow.adapter, - ) - - assert workspace.statements == [] - - -def test_denied_deployment_authorization_fails_before_native_mutation() -> None: - workspace = _StatefulWorkspace(present=False) - workflow = _workflow( - workspace, - _property("id", "integer", required=True), - ) - preview = workflow.service.preview( + workflow.service.apply( workflow.plan, + preview, adapter=workflow.adapter, ) - denied = workflow.authorization.model_copy( - update={ - "allowed": False, - "deployment_authorization_id": compute_deployment_authorization_id( - deployment_plan_id=workflow.plan.deployment_plan_id, - source_snapshot_id=workflow.plan.source_snapshot_id, - authorization_kind="contractops", - authorization_reference=workflow.authorization.authorization_reference, - allowed=False, - ), - } - ) - - with pytest.raises(ContractOpsAuthorizationError, match="not allowed"): - workflow.service.execute( - workflow.plan, - preview, - denied, - adapter=workflow.adapter, - ) assert workspace.statements == [] + def test_databricks_integer_target_alias_does_not_create_false_drift() -> None: workspace = _StatefulWorkspace( present=True, From 8a3340f68ca38a00c9b7cb006415d92f0d19b1ef Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:52:56 +1000 Subject: [PATCH 363/616] docs(deployment): remove stale compatibility wording --- semapact/deployment/__init__.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index 4e1049db..f1220884 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -1,8 +1,4 @@ -"""Provider-neutral deployment planning and orchestration boundary. - -Legacy authorization symbols remain importable for compatibility but are not part of -the canonical public surface. -""" +"""Provider-neutral deployment planning and orchestration boundary.""" from semapact.deployment.adapters import ( DeploymentAdapter, From e8ff7fd5d5d4d2e81793a10ac68dfe0c68f41586 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:52:59 +1000 Subject: [PATCH 364/616] docs(deployment): describe canonical provenance validation --- semapact/deployment/authorization.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py index 0de97cf7..e425b913 100644 --- a/semapact/deployment/authorization.py +++ b/semapact/deployment/authorization.py @@ -1,8 +1,4 @@ -"""Legacy in-process deployment authorization plus canonical provenance validation. - -Canonical bundle-driven CD uses the validate_* context functions below and leaves -runtime execution permission to the surrounding protected deployment context. -""" +"""Canonical deployment provenance validation.""" from __future__ import annotations From 29e9568eea8919a8da60b15a59d33259ec9d2c1e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:53:12 +1000 Subject: [PATCH 365/616] style(contractops): remove obsolete authorization import --- semapact/contractops/execution.py | 1 - 1 file changed, 1 deletion(-) diff --git a/semapact/contractops/execution.py b/semapact/contractops/execution.py index be325f48..5c9a1f3c 100644 --- a/semapact/contractops/execution.py +++ b/semapact/contractops/execution.py @@ -9,7 +9,6 @@ from semapact.contractops.integrity import compute_release_snapshot_id from semapact.contractops.models import ( ChangeSet, - ContractOpsAuthorization, ReleasePlan, VersionResolution, ) From d0d4ddfda56b25f6d17cde5f453d0416a321db9c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:53:15 +1000 Subject: [PATCH 366/616] docs(contractops): describe canonical release artifacts --- semapact/contractops/execution_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/contractops/execution_models.py b/semapact/contractops/execution_models.py index 03d1199e..68b2c3f6 100644 --- a/semapact/contractops/execution_models.py +++ b/semapact/contractops/execution_models.py @@ -1,4 +1,4 @@ -"""Immutable artifacts for explicit ContractOps APPLY and PUBLISH phases.""" +"""Immutable artifacts for canonical ContractOps release flow.""" from __future__ import annotations From 839235d8b66e19e20cc69700b2de8bd5014dbae1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:53:47 +1000 Subject: [PATCH 367/616] refactor(history): remove obsolete release deployment runtime records --- semapact/history/models.py | 203 ------------------------------------- 1 file changed, 203 deletions(-) diff --git a/semapact/history/models.py b/semapact/history/models.py index e16d5b04..edc21cf5 100644 --- a/semapact/history/models.py +++ b/semapact/history/models.py @@ -6,20 +6,10 @@ from __future__ import annotations -from datetime import datetime, timezone from enum import Enum from pydantic import BaseModel, ConfigDict, field_validator, model_validator -from semapact.contractops.execution_models import ContractRelease -from semapact.contractops.models import ReviewEvidenceAction, VersionAuthority -from semapact.observation import ObservedPlatformState -from semapact.reconciliation import ( - ReconciliationResult, - RuntimeDriftStatus, - classify_reconciliation_status, -) -from semapact.versioning import ActualVersionBump, RequiredBump class HistoryModel(BaseModel): @@ -73,199 +63,6 @@ def _require_non_empty_text(cls, value: str) -> str: return _required_text(value) -class ReleaseRecord(HistoryModel): - """Durable audit projection for one finalized governed contract release. - - Canonical ContractOps artifacts remain authoritative for planning, versioning, - authorization, and APPLY semantics. This record freezes the stable links and - final facts required to reconstruct one released semantic version. - """ - - release_record_id: str - contract_id: str - contract_version: str - decision_id: str - change_set_id: str - release_plan_id: str - version_resolution_id: str - authorization_id: str - applied_release_id: str - released_revision_id: str - required_version_bump: RequiredBump - actual_version_bump: ActualVersionBump - version_authority: VersionAuthority - authority_reference: str | None = None - review_evidence_reference: str | None = None - review_evidence_action: ReviewEvidenceAction | None = None - - @field_validator( - "release_record_id", - "contract_id", - "contract_version", - "decision_id", - "change_set_id", - "release_plan_id", - "version_resolution_id", - "authorization_id", - "applied_release_id", - "released_revision_id", - ) - @classmethod - def _require_release_text(cls, value: str) -> str: - return _required_text(value) - - @field_validator( - "authority_reference", - "review_evidence_reference", - ) - @classmethod - def _normalize_optional_text(cls, value: str | None) -> str | None: - return _optional_text(value) - - @model_validator(mode="after") - def _validate_provenance_pairs(self) -> ReleaseRecord: - if self.version_authority is VersionAuthority.GIT: - if self.authority_reference is None: - raise ValueError("git release history requires authority_reference") - elif self.authority_reference is not None: - raise ValueError( - "SemaPact release history must not contain authority_reference" - ) - - has_reference = self.review_evidence_reference is not None - has_action = self.review_evidence_action is not None - if has_reference != has_action: - raise ValueError( - "review evidence reference and action must either both be present or both be absent" - ) - return self - - -# Backward-compatible persistence name. Canonical ownership lives in ContractOps. -ContractReleaseRecord = ContractRelease - - -# Legacy Git-backed operational records. Canonical high-frequency deployment -# telemetry lives in semapact.history.operational and is never written to Git by -# the bundle-driven workflow. -class DeploymentStatus(str, Enum): - """Terminal provider-execution outcome for one deployment occurrence.""" - - SUCCEEDED = "SUCCEEDED" - FAILED = "FAILED" - - -class DeploymentRecord(HistoryModel): - """Immutable audit record for one completed deployment execution occurrence. - - This records provider execution only. ``SUCCEEDED`` never implies runtime - convergence; reconciliation remains a separate observation/history concern. - """ - - deployment_record_id: str - release_record_id: str - deployment_plan_id: str - deployment_preview_id: str - deployment_authorization_id: str - platform: str - runtime_target: str - source_reference: str - status: DeploymentStatus - started_at: datetime - completed_at: datetime - actor_reference: str | None = None - external_reference: str | None = None - - @field_validator( - "deployment_record_id", - "release_record_id", - "deployment_plan_id", - "deployment_preview_id", - "deployment_authorization_id", - "runtime_target", - "source_reference", - ) - @classmethod - def _require_deployment_text(cls, value: str) -> str: - return _required_text(value) - - @field_validator("platform") - @classmethod - def _normalize_platform(cls, value: str) -> str: - return _required_text(value).casefold() - - @field_validator("actor_reference", "external_reference") - @classmethod - def _normalize_optional_deployment_text(cls, value: str | None) -> str | None: - return _optional_text(value) - - @field_validator("started_at", "completed_at") - @classmethod - def _normalize_timestamp(cls, value: datetime) -> datetime: - if value.tzinfo is None or value.utcoffset() is None: - raise ValueError("deployment timestamps must be timezone-aware") - return value.astimezone(timezone.utc) - - @model_validator(mode="after") - def _validate_timestamp_order(self) -> DeploymentRecord: - if self.completed_at < self.started_at: - raise ValueError("completed_at must not be earlier than started_at") - return self - - -class RuntimeObservationRecord(HistoryModel): - """Content-addressed history envelope around canonical M1 runtime evidence.""" - - observation_record_id: str - observation: ObservedPlatformState - - @field_validator("observation_record_id") - @classmethod - def _require_observation_id(cls, value: str) -> str: - return _required_text(value) - - -class RuntimeReconciliationRecord(HistoryModel): - """Immutable linkage from canonical runtime evidence to optional lifecycle context. - - ``result`` remains the canonical M1 ReconciliationResult. This record adds only - durable identity, point-in-time classification, and optional exact history links. - """ - - runtime_reconciliation_record_id: str - observation_record_id: str - result: ReconciliationResult - status: RuntimeDriftStatus - release_record_id: str | None = None - deployment_record_id: str | None = None - - @field_validator( - "runtime_reconciliation_record_id", - "observation_record_id", - ) - @classmethod - def _require_runtime_history_text(cls, value: str) -> str: - return _required_text(value) - - @field_validator("release_record_id", "deployment_record_id") - @classmethod - def _normalize_runtime_links(cls, value: str | None) -> str | None: - return _optional_text(value) - - @model_validator(mode="after") - def _validate_runtime_history_semantics(self) -> RuntimeReconciliationRecord: - expected_status = classify_reconciliation_status(self.result) - if self.status is not expected_status: - raise ValueError( - "runtime reconciliation status does not match canonical classification" - ) - if self.deployment_record_id is not None and self.release_record_id is None: - raise ValueError( - "deployment-linked runtime history requires release_record_id" - ) - return self - - def _required_text(value: str) -> str: if not isinstance(value, str): raise TypeError("history identifiers must be strings") From c9c18059d40a0da99e3d47fd4738a7872f16f338 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:53:50 +1000 Subject: [PATCH 368/616] refactor(history): keep governance ledger repositories only --- semapact/history/repository.py | 103 --------------------------------- 1 file changed, 103 deletions(-) diff --git a/semapact/history/repository.py b/semapact/history/repository.py index edece385..cab60e0b 100644 --- a/semapact/history/repository.py +++ b/semapact/history/repository.py @@ -6,16 +6,11 @@ from semapact.approval.models import ApprovalRecord from semapact.contractops import ChangeSet, ContractRelease, ReleasePlan -from semapact.deployment import DeploymentAuthorization, DeploymentPlan, DeploymentPreview from semapact.governance import GovernanceDecision from semapact.governance.gate import GovernanceOperation from semapact.history.models import ( ChangeSetDecisionLink, - DeploymentRecord, HistoryStorageIntegrityIssue, - ReleaseRecord, - RuntimeObservationRecord, - RuntimeReconciliationRecord, ) from semapact.revision.models import ContractRevision, ContractRevisionSource @@ -113,20 +108,6 @@ def put_release_plan(self, release_plan: ReleasePlan) -> None: ... def get_release_plan(self, release_plan_id: str) -> ReleasePlan: ... -class ReleaseRecordHistoryRepository(Protocol): - """Persistence capability for finalized release audit records only.""" - - def put_release_record(self, record: ReleaseRecord) -> None: ... - def get_release_record(self, release_record_id: str) -> ReleaseRecord: ... - def list_release_records(self, contract_id: str) -> tuple[ReleaseRecord, ...]: ... - - def get_release_record_by_version( - self, - contract_id: str, - contract_version: str, - ) -> ReleaseRecord: ... - - class ContractReleaseHistoryRepository(Protocol): """Persistence capability for finalized formal contract release facts.""" @@ -141,87 +122,3 @@ def get_contract_release_by_version( contract_id: str, contract_version: str, ) -> ContractRelease: ... - - -class DeploymentPlanHistoryRepository(Protocol): - """Persistence capability for canonical DeploymentPlan history only.""" - - def put_deployment_plan(self, plan: DeploymentPlan) -> None: ... - def get_deployment_plan(self, deployment_plan_id: str) -> DeploymentPlan: ... - - -class DeploymentPreviewHistoryRepository(Protocol): - """Persistence capability for canonical DeploymentPreview history only.""" - - def put_deployment_preview(self, preview: DeploymentPreview) -> None: ... - def get_deployment_preview(self, deployment_preview_id: str) -> DeploymentPreview: ... - - -class DeploymentAuthorizationHistoryRepository(Protocol): - """Persistence capability for canonical DeploymentAuthorization history only.""" - - def put_deployment_authorization( - self, - authorization: DeploymentAuthorization, - ) -> None: ... - - def get_deployment_authorization( - self, - deployment_authorization_id: str, - ) -> DeploymentAuthorization: ... - - -class DeploymentRecordHistoryRepository(Protocol): - """Persistence capability for terminal deployment execution occurrences only.""" - - def put_deployment_record(self, record: DeploymentRecord) -> None: ... - def get_deployment_record(self, deployment_record_id: str) -> DeploymentRecord: ... - - def list_deployment_records_for_release( - self, - release_record_id: str, - ) -> tuple[DeploymentRecord, ...]: ... - - def list_deployment_records_for_plan( - self, - deployment_plan_id: str, - ) -> tuple[DeploymentRecord, ...]: ... - - -class RuntimeObservationHistoryRepository(Protocol): - """Persistence capability for canonical M1 observation evidence envelopes.""" - - def put_runtime_observation_record(self, record: RuntimeObservationRecord) -> None: ... - def get_runtime_observation_record( - self, - observation_record_id: str, - ) -> RuntimeObservationRecord: ... - - def list_runtime_observation_records( - self, - source_identifier: str, - ) -> tuple[RuntimeObservationRecord, ...]: ... - - -class RuntimeReconciliationHistoryRepository(Protocol): - """Persistence capability for point-in-time runtime reconciliation history.""" - - def put_runtime_reconciliation_record( - self, - record: RuntimeReconciliationRecord, - ) -> None: ... - - def get_runtime_reconciliation_record( - self, - runtime_reconciliation_record_id: str, - ) -> RuntimeReconciliationRecord: ... - - def list_runtime_reconciliation_records( - self, - contract_id: str, - ) -> tuple[RuntimeReconciliationRecord, ...]: ... - - def list_runtime_reconciliation_records_for_source( - self, - source_identifier: str, - ) -> tuple[RuntimeReconciliationRecord, ...]: ... From 3512e70cfc2d1210db9057f161fe92a4d6d91c6a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:53:53 +1000 Subject: [PATCH 369/616] refactor(history): expose only canonical ledger and operational sink --- semapact/history/__init__.py | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/semapact/history/__init__.py b/semapact/history/__init__.py index 2f3d3935..35b8e114 100644 --- a/semapact/history/__init__.py +++ b/semapact/history/__init__.py @@ -1,21 +1,9 @@ -"""Durable governance-history persistence boundary. - -History stores canonical governance artifacts without becoming a second source of -revision, governance, ContractOps, deployment, or reconciliation semantics. -Legacy Git-backed operational-history symbols remain importable for compatibility -but are intentionally excluded from the canonical public export surface. -""" +"""Durable governance-history persistence boundary.""" from semapact.history.models import ( ChangeSetDecisionLink, - ContractReleaseRecord, - DeploymentRecord, - DeploymentStatus, HistoryIntegrityIssueCode, HistoryStorageIntegrityIssue, - ReleaseRecord, - RuntimeObservationRecord, - RuntimeReconciliationRecord, ) from semapact.history.repository import ( ApprovalHistoryRepository, @@ -25,19 +13,12 @@ ContractRevisionHistoryRepository, ContractRevisionSourceHistoryRepository, DecisionHistoryRepository, - DeploymentAuthorizationHistoryRepository, - DeploymentPlanHistoryRepository, - DeploymentPreviewHistoryRepository, - DeploymentRecordHistoryRepository, HistoryConflictError, HistoryCorruptionError, HistoryIntegrityRepository, HistoryNotFoundError, HistoryRepositoryError, ReleasePlanHistoryRepository, - ReleaseRecordHistoryRepository, - RuntimeObservationHistoryRepository, - RuntimeReconciliationHistoryRepository, ) __all__ = [ @@ -46,7 +27,6 @@ "ChangeSetDecisionLinkHistoryRepository", "ChangeSetHistoryRepository", "ContractReleaseHistoryRepository", - "ContractReleaseRecord", "ContractRevisionHistoryRepository", "ContractRevisionSourceHistoryRepository", "DecisionHistoryRepository", From c7afe299644f90255ca021be83388e74f07139ef Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:54:21 +1000 Subject: [PATCH 370/616] refactor(history): keep Git ledger governance-only --- semapact/platforms/git/history_repository.py | 289 ++----------------- 1 file changed, 19 insertions(+), 270 deletions(-) diff --git a/semapact/platforms/git/history_repository.py b/semapact/platforms/git/history_repository.py index 50ddaa20..b7558758 100644 --- a/semapact/platforms/git/history_repository.py +++ b/semapact/platforms/git/history_repository.py @@ -11,53 +11,30 @@ import os import re import secrets -from collections.abc import Callable, Mapping +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import Any, Generic, TypeVar +from typing import Generic, TypeVar from pydantic import BaseModel, ValidationError as PydanticValidationError from semapact.approval.integrity import validate_approval_record_identity from semapact.approval.models import ApprovalRecord -from semapact.contractops import ChangeSet, ReleasePlan +from semapact.contractops import ChangeSet, ContractRelease, ReleasePlan from semapact.contractops.integrity import ( validate_change_set_identity, + validate_contract_release_identity, validate_release_plan_identity, ) -from semapact.deployment import DeploymentAuthorization, DeploymentPlan, DeploymentPreview -from semapact.deployment.compatibility import ( - parse_deployment_authorization_payload, - parse_deployment_plan_payload, - serialize_deployment_authorization_payload, - serialize_deployment_plan_payload, -) -from semapact.deployment.models import ( - validate_deployment_authorization_identity, - validate_deployment_plan_identity, - validate_deployment_preview_identity, -) from semapact.governance import GovernanceDecision from semapact.governance.gate import GovernanceOperation from semapact.history import ( ChangeSetDecisionLink, - ContractReleaseRecord, - DeploymentRecord, HistoryConflictError, HistoryCorruptionError, HistoryIntegrityIssueCode, HistoryNotFoundError, HistoryStorageIntegrityIssue, - ReleaseRecord, - RuntimeObservationRecord, - RuntimeReconciliationRecord, -) -from semapact.history.integrity import ( - validate_contract_release_record_identity, - validate_deployment_record_identity, - validate_release_record_identity, - validate_runtime_observation_record_identity, - validate_runtime_reconciliation_record_identity, ) from semapact.revision.integrity import ( validate_contract_revision_identity, @@ -80,8 +57,6 @@ class _HistoryKindSpec(Generic[T]): model_type: type[T] id_attribute: str integrity_validator: Callable[[T], None] | None = None - parser: Callable[[Mapping[str, Any]], T] | None = None - serializer: Callable[[T], dict[str, Any]] | None = None _DECISIONS = _HistoryKindSpec( @@ -124,57 +99,11 @@ class _HistoryKindSpec(Generic[T]): "release_plan_id", validate_release_plan_identity, ) -_RELEASE_RECORDS = _HistoryKindSpec( - "release_records", - ReleaseRecord, - "release_record_id", - validate_release_record_identity, -) _CONTRACT_RELEASES = _HistoryKindSpec( "contract_releases", - ContractReleaseRecord, + ContractRelease, "contract_release_id", - validate_contract_release_record_identity, -) -_DEPLOYMENT_PLANS = _HistoryKindSpec( - "deployment_plans", - DeploymentPlan, - "deployment_plan_id", - validate_deployment_plan_identity, - parse_deployment_plan_payload, - serialize_deployment_plan_payload, -) -_DEPLOYMENT_PREVIEWS = _HistoryKindSpec( - "deployment_previews", - DeploymentPreview, - "deployment_preview_id", - validate_deployment_preview_identity, -) -_DEPLOYMENT_AUTHORIZATIONS = _HistoryKindSpec( - "deployment_authorizations", - DeploymentAuthorization, - "deployment_authorization_id", - validate_deployment_authorization_identity, - parse_deployment_authorization_payload, - serialize_deployment_authorization_payload, -) -_DEPLOYMENT_RECORDS = _HistoryKindSpec( - "deployment_records", - DeploymentRecord, - "deployment_record_id", - validate_deployment_record_identity, -) -_RUNTIME_OBSERVATIONS = _HistoryKindSpec( - "runtime_observations", - RuntimeObservationRecord, - "observation_record_id", - validate_runtime_observation_record_identity, -) -_RUNTIME_RECONCILIATIONS = _HistoryKindSpec( - "runtime_reconciliations", - RuntimeReconciliationRecord, - "runtime_reconciliation_record_id", - validate_runtime_reconciliation_record_identity, + validate_contract_release_identity, ) _HISTORY_KIND_SPECS: dict[str, _HistoryKindSpec[BaseModel]] = { @@ -187,14 +116,7 @@ class _HistoryKindSpec(Generic[T]): _CONTRACT_REVISIONS, _CONTRACT_REVISION_SOURCES, _RELEASE_PLANS, - _RELEASE_RECORDS, _CONTRACT_RELEASES, - _DEPLOYMENT_PLANS, - _DEPLOYMENT_PREVIEWS, - _DEPLOYMENT_AUTHORIZATIONS, - _DEPLOYMENT_RECORDS, - _RUNTIME_OBSERVATIONS, - _RUNTIME_RECONCILIATIONS, ) } @@ -352,70 +274,20 @@ def put_release_plan(self, release_plan: ReleasePlan) -> None: def get_release_plan(self, release_plan_id: str) -> ReleasePlan: return self._get(_RELEASE_PLANS, release_plan_id) - def put_release_record(self, record: ReleaseRecord) -> None: - if not isinstance(record, ReleaseRecord): + def put_contract_release(self, record: ContractRelease) -> None: + if not isinstance(record, ContractRelease): raise TypeError( - f"record must be ReleaseRecord, got {type(record).__name__}" - ) - validate_release_record_identity(record) - for existing in self.list_release_records(record.contract_id): - if ( - existing.contract_version == record.contract_version - and existing.release_record_id != record.release_record_id - ): - raise HistoryConflictError( - "A different ReleaseRecord already exists for " - f"{record.contract_id!r} version {record.contract_version!r}" - ) - self._put(_RELEASE_RECORDS, record.release_record_id, record) - - def get_release_record(self, release_record_id: str) -> ReleaseRecord: - return self._get(_RELEASE_RECORDS, release_record_id) - - def list_release_records(self, contract_id: str) -> tuple[ReleaseRecord, ...]: - contract_id = _required_text(contract_id, "contract_id") - return tuple( - record - for record in self._list(_RELEASE_RECORDS) - if record.contract_id == contract_id - ) - - def get_release_record_by_version( - self, - contract_id: str, - contract_version: str, - ) -> ReleaseRecord: - contract_id = _required_text(contract_id, "contract_id") - contract_version = _required_text(contract_version, "contract_version") - matches = tuple( - record - for record in self.list_release_records(contract_id) - if record.contract_version == contract_version - ) - if not matches: - raise HistoryNotFoundError( - f"ReleaseRecord for {contract_id!r} version {contract_version!r} was not found" - ) - if len(matches) != 1: - raise HistoryCorruptionError( - f"Multiple ReleaseRecords exist for {contract_id!r} version {contract_version!r}" - ) - return matches[0] - - def put_contract_release(self, record: ContractReleaseRecord) -> None: - if not isinstance(record, ContractReleaseRecord): - raise TypeError( - "record must be ContractReleaseRecord, " + "record must be ContractRelease, " f"got {type(record).__name__}" ) - validate_contract_release_record_identity(record) + validate_contract_release_identity(record) for existing in self.list_contract_releases(record.contract_id): if ( existing.contract_version == record.contract_version and existing.contract_release_id != record.contract_release_id ): raise HistoryConflictError( - "A different ContractReleaseRecord already exists for " + "A different ContractRelease already exists for " f"{record.contract_id!r} version {record.contract_version!r}" ) self._put(_CONTRACT_RELEASES, record.contract_release_id, record) @@ -423,13 +295,13 @@ def put_contract_release(self, record: ContractReleaseRecord) -> None: def get_contract_release( self, contract_release_id: str, - ) -> ContractReleaseRecord: + ) -> ContractRelease: return self._get(_CONTRACT_RELEASES, contract_release_id) def list_contract_releases( self, contract_id: str, - ) -> tuple[ContractReleaseRecord, ...]: + ) -> tuple[ContractRelease, ...]: contract_id = _required_text(contract_id, "contract_id") return tuple( record @@ -441,7 +313,7 @@ def get_contract_release_by_version( self, contract_id: str, contract_version: str, - ) -> ContractReleaseRecord: + ) -> ContractRelease: contract_id = _required_text(contract_id, "contract_id") contract_version = _required_text(contract_version, "contract_version") matches = tuple( @@ -451,133 +323,16 @@ def get_contract_release_by_version( ) if not matches: raise HistoryNotFoundError( - f"ContractReleaseRecord for {contract_id!r} " + f"ContractRelease for {contract_id!r} " f"version {contract_version!r} was not found" ) if len(matches) != 1: raise HistoryCorruptionError( - f"Multiple ContractReleaseRecords exist for {contract_id!r} " + f"Multiple ContractReleases exist for {contract_id!r} " f"version {contract_version!r}" ) return matches[0] - def put_deployment_plan(self, plan: DeploymentPlan) -> None: - self._put(_DEPLOYMENT_PLANS, plan.deployment_plan_id, plan) - - def get_deployment_plan(self, deployment_plan_id: str) -> DeploymentPlan: - return self._get(_DEPLOYMENT_PLANS, deployment_plan_id) - - def put_deployment_preview(self, preview: DeploymentPreview) -> None: - self._put(_DEPLOYMENT_PREVIEWS, preview.deployment_preview_id, preview) - - def get_deployment_preview(self, deployment_preview_id: str) -> DeploymentPreview: - return self._get(_DEPLOYMENT_PREVIEWS, deployment_preview_id) - - def put_deployment_authorization( - self, - authorization: DeploymentAuthorization, - ) -> None: - self._put( - _DEPLOYMENT_AUTHORIZATIONS, - authorization.deployment_authorization_id, - authorization, - ) - - def get_deployment_authorization( - self, - deployment_authorization_id: str, - ) -> DeploymentAuthorization: - return self._get(_DEPLOYMENT_AUTHORIZATIONS, deployment_authorization_id) - - def put_deployment_record(self, record: DeploymentRecord) -> None: - self._put(_DEPLOYMENT_RECORDS, record.deployment_record_id, record) - - def get_deployment_record(self, deployment_record_id: str) -> DeploymentRecord: - return self._get(_DEPLOYMENT_RECORDS, deployment_record_id) - - def list_deployment_records_for_release( - self, - release_record_id: str, - ) -> tuple[DeploymentRecord, ...]: - release_record_id = _required_text(release_record_id, "release_record_id") - return tuple( - record - for record in self._list(_DEPLOYMENT_RECORDS) - if record.release_record_id == release_record_id - ) - - def list_deployment_records_for_plan( - self, - deployment_plan_id: str, - ) -> tuple[DeploymentRecord, ...]: - deployment_plan_id = _required_text(deployment_plan_id, "deployment_plan_id") - return tuple( - record - for record in self._list(_DEPLOYMENT_RECORDS) - if record.deployment_plan_id == deployment_plan_id - ) - - def put_runtime_observation_record(self, record: RuntimeObservationRecord) -> None: - self._put(_RUNTIME_OBSERVATIONS, record.observation_record_id, record) - - def get_runtime_observation_record( - self, - observation_record_id: str, - ) -> RuntimeObservationRecord: - return self._get(_RUNTIME_OBSERVATIONS, observation_record_id) - - def list_runtime_observation_records( - self, - source_identifier: str, - ) -> tuple[RuntimeObservationRecord, ...]: - source_identifier = _required_text(source_identifier, "source_identifier") - return tuple( - record - for record in self._list(_RUNTIME_OBSERVATIONS) - if record.observation.source_identifier == source_identifier - ) - - def put_runtime_reconciliation_record( - self, - record: RuntimeReconciliationRecord, - ) -> None: - self._put( - _RUNTIME_RECONCILIATIONS, - record.runtime_reconciliation_record_id, - record, - ) - - def get_runtime_reconciliation_record( - self, - runtime_reconciliation_record_id: str, - ) -> RuntimeReconciliationRecord: - return self._get( - _RUNTIME_RECONCILIATIONS, - runtime_reconciliation_record_id, - ) - - def list_runtime_reconciliation_records( - self, - contract_id: str, - ) -> tuple[RuntimeReconciliationRecord, ...]: - contract_id = _required_text(contract_id, "contract_id") - return tuple( - record - for record in self._list(_RUNTIME_RECONCILIATIONS) - if record.result.contract_id == contract_id - ) - - def list_runtime_reconciliation_records_for_source( - self, - source_identifier: str, - ) -> tuple[RuntimeReconciliationRecord, ...]: - source_identifier = _required_text(source_identifier, "source_identifier") - return tuple( - record - for record in self._list(_RUNTIME_RECONCILIATIONS) - if record.result.observation_source_identifier == source_identifier - ) - def inspect_history_integrity(self) -> tuple[HistoryStorageIntegrityIssue, ...]: """Inspect physical history artifacts and checksum evidence without mutation.""" root_issue = self._inspect_history_root() @@ -1141,9 +896,8 @@ def _canonical_artifact_json( spec: _HistoryKindSpec[T], artifact: T, ) -> str: - """Serialize at the persistence boundary, including legacy wire compatibility.""" - if spec.serializer is not None: - return canonical_compact_json(spec.serializer(artifact)) + """Serialize one canonical persisted history artifact.""" + del spec return _canonical_model_json(artifact) @@ -1151,12 +905,7 @@ def _parse_artifact_json( spec: _HistoryKindSpec[T], raw: str, ) -> T: - """Rehydrate persisted wire data through the kind-specific boundary adapter.""" - if spec.parser is not None: - payload = json.loads(raw) - if not isinstance(payload, dict): - raise ValueError("Persisted history artifact must be a JSON object") - return spec.parser(payload) + """Rehydrate one canonical persisted history artifact.""" return spec.model_type.model_validate_json(raw) From 07b823f79027e5b4eda9b64065e32f4de26a29c6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:55:04 +1000 Subject: [PATCH 371/616] refactor(history): model governance evolution only --- semapact/application/models/evolution.py | 38 ++++-------------------- 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/semapact/application/models/evolution.py b/semapact/application/models/evolution.py index 655c08a5..1d9021a0 100644 --- a/semapact/application/models/evolution.py +++ b/semapact/application/models/evolution.py @@ -4,20 +4,14 @@ from dataclasses import dataclass -from semapact.contractops import ChangeSet +from semapact.contractops import ChangeSet, ContractRelease from semapact.governance import GovernanceDecision -from semapact.history import ( - DeploymentRecord, - ReleaseRecord, - RuntimeObservationRecord, - RuntimeReconciliationRecord, -) from semapact.revision import ContractRevision @dataclass(frozen=True) class BrokenHistoryReference: - """One explicit history reference that could not be resolved consistently.""" + """One explicit governance-history reference that could not be resolved.""" source_kind: str source_id: str @@ -27,35 +21,16 @@ class BrokenHistoryReference: reason: str -@dataclass(frozen=True) -class RuntimeEvolution: - """One reconciliation occurrence with its exact observed-state evidence when present.""" - - reconciliation: RuntimeReconciliationRecord - observation: RuntimeObservationRecord | None - - -@dataclass(frozen=True) -class DeploymentEvolution: - """One deployment occurrence and runtime evidence explicitly linked to it.""" - - deployment: DeploymentRecord - runtime: tuple[RuntimeEvolution, ...] = () - - @dataclass(frozen=True) class ReleaseEvolution: - """One finalized release and its downstream execution/runtime history.""" + """One finalized formal ContractRelease linked to its governance path.""" - release: ReleaseRecord - released_revision: ContractRevision | None - deployments: tuple[DeploymentEvolution, ...] = () - runtime: tuple[RuntimeEvolution, ...] = () + release: ContractRelease @dataclass(frozen=True) class ProposalEvolution: - """One ChangeSet path through an optional recorded GovernanceDecision.""" + """One ChangeSet path through an optional GovernanceDecision and releases.""" change_set: ChangeSet base_revision: ContractRevision | None @@ -66,10 +41,9 @@ class ProposalEvolution: @dataclass(frozen=True) class ContractEvolution: - """Deterministic read-side reconstruction of one contract's persisted history.""" + """Deterministic read-side reconstruction of one contract governance history.""" contract_id: str proposals: tuple[ProposalEvolution, ...] = () unlinked_releases: tuple[ReleaseEvolution, ...] = () - unlinked_runtime: tuple[RuntimeEvolution, ...] = () broken_references: tuple[BrokenHistoryReference, ...] = () From 3ba44ebe54f2a664563ffaf6cac91a68d7207c20 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:55:08 +1000 Subject: [PATCH 372/616] refactor(history): reconstruct ContractRelease governance chain --- semapact/application/services/evolution.py | 276 ++------------------- 1 file changed, 23 insertions(+), 253 deletions(-) diff --git a/semapact/application/services/evolution.py b/semapact/application/services/evolution.py index 2daede25..92460824 100644 --- a/semapact/application/services/evolution.py +++ b/semapact/application/services/evolution.py @@ -1,35 +1,28 @@ -"""Read-only reconstruction of persisted governance evolution history.""" +"""Read-only reconstruction of canonical governance history.""" from __future__ import annotations from semapact.application.models.evolution import ( BrokenHistoryReference, ContractEvolution, - DeploymentEvolution, ProposalEvolution, ReleaseEvolution, - RuntimeEvolution, ) -from semapact.contractops import ChangeSet +from semapact.contractops import ChangeSet, ContractRelease from semapact.governance import GovernanceDecision from semapact.history import ( ChangeSetDecisionLinkHistoryRepository, ChangeSetHistoryRepository, + ContractReleaseHistoryRepository, ContractRevisionHistoryRepository, DecisionHistoryRepository, - DeploymentRecordHistoryRepository, HistoryNotFoundError, - ReleaseRecord, - ReleaseRecordHistoryRepository, - RuntimeObservationHistoryRepository, - RuntimeReconciliationHistoryRepository, - RuntimeReconciliationRecord, ) from semapact.revision import ContractRevision class EvolutionChainService: - """Reconstruct one contract's history from existing stable artifact references.""" + """Reconstruct proposal → decision → ContractRelease governance history.""" def __init__( self, @@ -38,22 +31,15 @@ def __init__( change_sets: ChangeSetHistoryRepository, decisions: DecisionHistoryRepository, decision_links: ChangeSetDecisionLinkHistoryRepository, - releases: ReleaseRecordHistoryRepository, - deployments: DeploymentRecordHistoryRepository, - observations: RuntimeObservationHistoryRepository, - runtime_reconciliations: RuntimeReconciliationHistoryRepository, + releases: ContractReleaseHistoryRepository, ) -> None: self._revisions = revisions self._change_sets = change_sets self._decisions = decisions self._decision_links = decision_links self._releases = releases - self._deployments = deployments - self._observations = observations - self._runtime_reconciliations = runtime_reconciliations def reconstruct(self, contract_id: str) -> ContractEvolution: - """Return a deterministic read model without persisting a second history graph.""" contract_id = _required_text(contract_id, "contract_id") issues: list[BrokenHistoryReference] = [] @@ -63,46 +49,20 @@ def reconstruct(self, contract_id: str) -> ContractEvolution: key=lambda item: item.change_set_id, ) ) - release_records = tuple( + releases = tuple( sorted( - self._releases.list_release_records(contract_id), - key=lambda item: item.release_record_id, + self._releases.list_contract_releases(contract_id), + key=lambda item: item.contract_release_id, ) ) - runtime_records = tuple( - sorted( - self._runtime_reconciliations.list_runtime_reconciliation_records( - contract_id - ), - key=lambda item: item.runtime_reconciliation_record_id, - ) - ) - - runtime_by_id = { - record.runtime_reconciliation_record_id: self._runtime_evolution( - record, - issues, - ) - for record in runtime_records - } - runtime_by_release: dict[str, list[RuntimeReconciliationRecord]] = {} - runtime_by_deployment: dict[str, list[RuntimeReconciliationRecord]] = {} - for record in runtime_records: - if record.deployment_record_id is not None: - runtime_by_deployment.setdefault(record.deployment_record_id, []).append(record) - elif record.release_record_id is not None: - runtime_by_release.setdefault(record.release_record_id, []).append(record) - - releases_by_path: dict[tuple[str, str], list[ReleaseRecord]] = {} - for release in release_records: + releases_by_path: dict[tuple[str, str], list[ContractRelease]] = {} + for release in releases: releases_by_path.setdefault( (release.change_set_id, release.decision_id), [], ).append(release) consumed_release_ids: set[str] = set() - consumed_runtime_ids: set[str] = set() - known_deployment_ids: set[str] = set() linked_decision_pairs: set[tuple[str, str]] = set() proposals: list[ProposalEvolution] = [] @@ -149,24 +109,15 @@ def reconstruct(self, contract_id: str) -> ContractEvolution: contract_id=contract_id, issues=issues, ) - releases = tuple( - self._build_release( - release, - contract_id=contract_id, - runtime_by_id=runtime_by_id, - runtime_by_release=runtime_by_release, - runtime_by_deployment=runtime_by_deployment, - consumed_runtime_ids=consumed_runtime_ids, - known_deployment_ids=known_deployment_ids, - issues=issues, - ) + linked_releases = tuple( + ReleaseEvolution(release=release) for release in sorted( releases_by_path.get(pair, ()), - key=lambda item: item.release_record_id, + key=lambda item: item.contract_release_id, ) ) consumed_release_ids.update( - item.release.release_record_id for item in releases + item.release.contract_release_id for item in linked_releases ) proposals.append( ProposalEvolution( @@ -174,21 +125,21 @@ def reconstruct(self, contract_id: str) -> ContractEvolution: base_revision=base_revision, candidate_revision=candidate_revision, decision=decision, - releases=releases, + releases=linked_releases, ) ) change_set_ids = {item.change_set_id for item in change_sets} unlinked_releases: list[ReleaseEvolution] = [] - for release in release_records: - if release.release_record_id in consumed_release_ids: + for release in releases: + if release.contract_release_id in consumed_release_ids: continue pair = (release.change_set_id, release.decision_id) if release.change_set_id not in change_set_ids: _add_issue( issues, - source_kind="ReleaseRecord", - source_id=release.release_record_id, + source_kind="ContractRelease", + source_id=release.contract_release_id, reference_field="change_set_id", target_kind="ChangeSet", target_id=release.change_set_id, @@ -197,59 +148,14 @@ def reconstruct(self, contract_id: str) -> ContractEvolution: elif pair not in linked_decision_pairs: _add_issue( issues, - source_kind="ReleaseRecord", - source_id=release.release_record_id, + source_kind="ContractRelease", + source_id=release.contract_release_id, reference_field="decision_id", target_kind="ChangeSetDecisionLink", target_id=f"{release.change_set_id}:{release.decision_id}", reason="RELATION_MISSING", ) - unlinked_releases.append( - self._build_release( - release, - contract_id=contract_id, - runtime_by_id=runtime_by_id, - runtime_by_release=runtime_by_release, - runtime_by_deployment=runtime_by_deployment, - consumed_runtime_ids=consumed_runtime_ids, - known_deployment_ids=known_deployment_ids, - issues=issues, - ) - ) - - release_ids = {item.release_record_id for item in release_records} - unlinked_runtime: list[RuntimeEvolution] = [] - for record in runtime_records: - record_id = record.runtime_reconciliation_record_id - if record_id in consumed_runtime_ids: - continue - if ( - record.release_record_id is not None - and record.release_record_id not in release_ids - ): - _add_issue( - issues, - source_kind="RuntimeReconciliationRecord", - source_id=record_id, - reference_field="release_record_id", - target_kind="ReleaseRecord", - target_id=record.release_record_id, - reason="NOT_FOUND", - ) - if ( - record.deployment_record_id is not None - and record.deployment_record_id not in known_deployment_ids - ): - _add_issue( - issues, - source_kind="RuntimeReconciliationRecord", - source_id=record_id, - reference_field="deployment_record_id", - target_kind="DeploymentRecord", - target_id=record.deployment_record_id, - reason="NOT_FOUND", - ) - unlinked_runtime.append(runtime_by_id[record_id]) + unlinked_releases.append(ReleaseEvolution(release=release)) proposals.sort( key=lambda item: ( @@ -257,10 +163,7 @@ def reconstruct(self, contract_id: str) -> ContractEvolution: item.decision.decision_id if item.decision is not None else "", ) ) - unlinked_releases.sort(key=lambda item: item.release.release_record_id) - unlinked_runtime.sort( - key=lambda item: item.reconciliation.runtime_reconciliation_record_id - ) + unlinked_releases.sort(key=lambda item: item.release.contract_release_id) issues.sort( key=lambda item: ( item.source_kind, @@ -275,7 +178,6 @@ def reconstruct(self, contract_id: str) -> ContractEvolution: contract_id=contract_id, proposals=tuple(proposals), unlinked_releases=tuple(unlinked_releases), - unlinked_runtime=tuple(unlinked_runtime), broken_references=tuple(issues), ) @@ -348,138 +250,6 @@ def _resolve_decision( return None return decision - def _build_release( - self, - release: ReleaseRecord, - *, - contract_id: str, - runtime_by_id: dict[str, RuntimeEvolution], - runtime_by_release: dict[str, list[RuntimeReconciliationRecord]], - runtime_by_deployment: dict[str, list[RuntimeReconciliationRecord]], - consumed_runtime_ids: set[str], - known_deployment_ids: set[str], - issues: list[BrokenHistoryReference], - ) -> ReleaseEvolution: - released_revision: ContractRevision | None - try: - released_revision = self._revisions.get_revision(release.released_revision_id) - except HistoryNotFoundError: - released_revision = None - _add_issue( - issues, - source_kind="ReleaseRecord", - source_id=release.release_record_id, - reference_field="released_revision_id", - target_kind="ContractRevision", - target_id=release.released_revision_id, - reason="NOT_FOUND", - ) - else: - if ( - str(released_revision.contract.id or "") != contract_id - or str(released_revision.contract.version or "") != release.contract_version - ): - _add_issue( - issues, - source_kind="ReleaseRecord", - source_id=release.release_record_id, - reference_field="released_revision_id", - target_kind="ContractRevision", - target_id=release.released_revision_id, - reason="RELEASE_MISMATCH", - ) - released_revision = None - - deployments = tuple( - sorted( - self._deployments.list_deployment_records_for_release( - release.release_record_id - ), - key=lambda item: item.deployment_record_id, - ) - ) - deployment_paths: list[DeploymentEvolution] = [] - for deployment in deployments: - known_deployment_ids.add(deployment.deployment_record_id) - linked_runtime: list[RuntimeEvolution] = [] - for record in sorted( - runtime_by_deployment.get(deployment.deployment_record_id, ()), - key=lambda item: item.runtime_reconciliation_record_id, - ): - if record.release_record_id != release.release_record_id: - _add_issue( - issues, - source_kind="RuntimeReconciliationRecord", - source_id=record.runtime_reconciliation_record_id, - reference_field="release_record_id", - target_kind="ReleaseRecord", - target_id=str(record.release_record_id or ""), - reason="DEPLOYMENT_RELEASE_MISMATCH", - ) - continue - linked_runtime.append(runtime_by_id[record.runtime_reconciliation_record_id]) - consumed_runtime_ids.add(record.runtime_reconciliation_record_id) - deployment_paths.append( - DeploymentEvolution( - deployment=deployment, - runtime=tuple(linked_runtime), - ) - ) - - release_runtime = tuple( - runtime_by_id[record.runtime_reconciliation_record_id] - for record in sorted( - runtime_by_release.get(release.release_record_id, ()), - key=lambda item: item.runtime_reconciliation_record_id, - ) - ) - consumed_runtime_ids.update( - item.reconciliation.runtime_reconciliation_record_id for item in release_runtime - ) - return ReleaseEvolution( - release=release, - released_revision=released_revision, - deployments=tuple(deployment_paths), - runtime=release_runtime, - ) - - def _runtime_evolution( - self, - record: RuntimeReconciliationRecord, - issues: list[BrokenHistoryReference], - ) -> RuntimeEvolution: - try: - observation = self._observations.get_runtime_observation_record( - record.observation_record_id - ) - except HistoryNotFoundError: - _add_issue( - issues, - source_kind="RuntimeReconciliationRecord", - source_id=record.runtime_reconciliation_record_id, - reference_field="observation_record_id", - target_kind="RuntimeObservationRecord", - target_id=record.observation_record_id, - reason="NOT_FOUND", - ) - return RuntimeEvolution(reconciliation=record, observation=None) - - observed = observation.observation - if ( - observed.source_identifier != record.result.observation_source_identifier - or observed.fingerprint != record.result.observation_fingerprint - ): - _add_issue( - issues, - source_kind="RuntimeReconciliationRecord", - source_id=record.runtime_reconciliation_record_id, - reference_field="observation_record_id", - target_kind="RuntimeObservationRecord", - target_id=record.observation_record_id, - reason="EVIDENCE_MISMATCH", - ) - return RuntimeEvolution(reconciliation=record, observation=observation) - def _add_issue( issues: list[BrokenHistoryReference], From da2e95dd644d06ec4cde880330bb635b81a5e78b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:55:17 +1000 Subject: [PATCH 373/616] refactor(history): remove obsolete git operational history --- .../services/deployment_history.py | 190 ------------------ 1 file changed, 190 deletions(-) delete mode 100644 semapact/application/services/deployment_history.py diff --git a/semapact/application/services/deployment_history.py b/semapact/application/services/deployment_history.py deleted file mode 100644 index f1439dbb..00000000 --- a/semapact/application/services/deployment_history.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Application orchestration for terminal deployment execution history.""" - -from __future__ import annotations - -from datetime import datetime, timezone - -from semapact.deployment import DeploymentAuthorization, DeploymentPlan, DeploymentPreview -from semapact.deployment.models import ( - validate_deployment_authorization_identity, - validate_deployment_plan_identity, - validate_deployment_preview_identity, -) -from semapact.history import ( - DeploymentAuthorizationHistoryRepository, - DeploymentPlanHistoryRepository, - DeploymentPreviewHistoryRepository, - DeploymentRecord, - DeploymentRecordHistoryRepository, - DeploymentStatus, - ReleaseRecord, - ReleaseRecordHistoryRepository, -) -from semapact.history.integrity import compute_deployment_record_id - - -class DeploymentHistoryService: - """Record already-observed provider execution outcomes without executing them.""" - - def __init__( - self, - *, - releases: ReleaseRecordHistoryRepository, - deployment_plans: DeploymentPlanHistoryRepository, - deployment_previews: DeploymentPreviewHistoryRepository, - deployment_authorizations: DeploymentAuthorizationHistoryRepository, - deployment_records: DeploymentRecordHistoryRepository, - ) -> None: - self._releases = releases - self._deployment_plans = deployment_plans - self._deployment_previews = deployment_previews - self._deployment_authorizations = deployment_authorizations - self._deployment_records = deployment_records - - def record_execution( - self, - *, - plan: DeploymentPlan, - preview: DeploymentPreview, - authorization: DeploymentAuthorization, - status: DeploymentStatus, - started_at: datetime, - completed_at: datetime, - actor_reference: str | None = None, - external_reference: str | None = None, - ) -> DeploymentRecord: - """Persist one terminal execution occurrence against exact deployment artifacts. - - The caller supplies the outcome after the provider execution boundary has - completed. This method never invokes a DeploymentAdapter and never infers - runtime convergence from execution success. - """ - _validate_types(plan, preview, authorization, status) - validate_deployment_plan_identity(plan) - validate_deployment_preview_identity(preview) - validate_deployment_authorization_identity(authorization) - - release = self._releases.get_release_record_by_version( - plan.contract_id, - plan.contract_version, - ) - _validate_release_link(release, plan) - _validate_preview_link(preview, plan) - _validate_authorization_link(authorization, plan) - - started = _utc_timestamp(started_at, "started_at") - completed = _utc_timestamp(completed_at, "completed_at") - if completed < started: - raise ValueError("completed_at must not be earlier than started_at") - - actor = _optional_text(actor_reference) - external = _optional_text(external_reference) - deployment_record_id = compute_deployment_record_id( - release_record_id=release.release_record_id, - deployment_plan_id=plan.deployment_plan_id, - deployment_preview_id=preview.deployment_preview_id, - deployment_authorization_id=authorization.deployment_authorization_id, - platform=plan.target.platform, - runtime_target=plan.target.runtime_target, - source_reference=plan.target.source_reference, - status=status.value, - started_at=started.isoformat(), - completed_at=completed.isoformat(), - actor_reference=actor, - external_reference=external, - ) - record = DeploymentRecord( - deployment_record_id=deployment_record_id, - release_record_id=release.release_record_id, - deployment_plan_id=plan.deployment_plan_id, - deployment_preview_id=preview.deployment_preview_id, - deployment_authorization_id=authorization.deployment_authorization_id, - platform=plan.target.platform, - runtime_target=plan.target.runtime_target, - source_reference=plan.target.source_reference, - status=status, - started_at=started, - completed_at=completed, - actor_reference=actor, - external_reference=external, - ) - - # Persist exact referenced artifacts before the occurrence record so a - # partial failure cannot leave history pointing at absent dependencies. - self._deployment_plans.put_deployment_plan(plan) - self._deployment_previews.put_deployment_preview(preview) - self._deployment_authorizations.put_deployment_authorization(authorization) - self._deployment_records.put_deployment_record(record) - return record - - -def _validate_types( - plan: DeploymentPlan, - preview: DeploymentPreview, - authorization: DeploymentAuthorization, - status: DeploymentStatus, -) -> None: - expected = ( - (plan, DeploymentPlan, "plan"), - (preview, DeploymentPreview, "preview"), - (authorization, DeploymentAuthorization, "authorization"), - (status, DeploymentStatus, "status"), - ) - for value, expected_type, name in expected: - if not isinstance(value, expected_type): - raise TypeError( - f"{name} must be {expected_type.__name__}, got {type(value).__name__}" - ) - - -def _validate_release_link(release: ReleaseRecord, plan: DeploymentPlan) -> None: - if ( - release.contract_id != plan.contract_id - or release.contract_version != plan.contract_version - or release.applied_release_id != plan.source_snapshot_id - ): - raise ValueError("DeploymentPlan does not match the finalized ReleaseRecord") - - -def _validate_preview_link(preview: DeploymentPreview, plan: DeploymentPlan) -> None: - if preview.deployment_plan_id != plan.deployment_plan_id: - raise ValueError("DeploymentPreview does not reference the supplied DeploymentPlan") - if preview.platform.strip().casefold() != plan.target.platform: - raise ValueError("DeploymentPreview platform does not match DeploymentPlan target") - if preview.runtime_target != plan.target.runtime_target: - raise ValueError("DeploymentPreview runtime target does not match DeploymentPlan") - if preview.source_identifier != plan.target.source_reference: - raise ValueError("DeploymentPreview source does not match DeploymentPlan source reference") - - -def _validate_authorization_link( - authorization: DeploymentAuthorization, - plan: DeploymentPlan, -) -> None: - if authorization.deployment_plan_id != plan.deployment_plan_id: - raise ValueError( - "DeploymentAuthorization does not reference the supplied DeploymentPlan" - ) - if authorization.source_snapshot_id != plan.source_snapshot_id: - raise ValueError( - "DeploymentAuthorization does not reference the plan source snapshot" - ) - if not authorization.allowed: - raise ValueError("deployment history requires an allowed DeploymentAuthorization") - - -def _utc_timestamp(value: datetime, field_name: str) -> datetime: - if not isinstance(value, datetime): - raise TypeError(f"{field_name} must be datetime") - if value.tzinfo is None or value.utcoffset() is None: - raise ValueError(f"{field_name} must be timezone-aware") - return value.astimezone(timezone.utc) - - -def _optional_text(value: str | None) -> str | None: - if value is None: - return None - if not isinstance(value, str): - raise TypeError("optional deployment references must be strings") - cleaned = value.strip() - return cleaned or None From 187615948951aada655ee450c0edd7dcab24eb9e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:55:20 +1000 Subject: [PATCH 374/616] refactor(history): remove obsolete git operational history --- .../application/services/release_history.py | 214 ------------------ 1 file changed, 214 deletions(-) delete mode 100644 semapact/application/services/release_history.py diff --git a/semapact/application/services/release_history.py b/semapact/application/services/release_history.py deleted file mode 100644 index bd8305b7..00000000 --- a/semapact/application/services/release_history.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Application orchestration for finalized contract release history.""" - -from __future__ import annotations - -from semapact.contractops.context import validate_release_context -from semapact.contractops.execution_models import AppliedContractRelease -from semapact.contractops.integrity import ( - validate_applied_release_identity, - validate_contractops_authorization_identity, -) -from semapact.contractops.models import ( - ContractOpsAuthorization, - ReleasePlan, - VersionResolution, -) -from semapact.governance.gate import GovernanceOperation -from semapact.history import ( - ChangeSetDecisionLinkHistoryRepository, - ChangeSetHistoryRepository, - ContractRevisionHistoryRepository, - DecisionHistoryRepository, - ReleasePlanHistoryRepository, - ReleaseRecord, - ReleaseRecordHistoryRepository, -) -from semapact.history.integrity import compute_release_record_id -from semapact.revision import build_contract_revision -from semapact.revision.integrity import validate_contract_revision_identity - - -class ReleaseHistoryService: - """Record one already-applied release without recomputing release semantics.""" - - def __init__( - self, - *, - revisions: ContractRevisionHistoryRepository, - change_sets: ChangeSetHistoryRepository, - decisions: DecisionHistoryRepository, - decision_links: ChangeSetDecisionLinkHistoryRepository, - release_plans: ReleasePlanHistoryRepository, - release_records: ReleaseRecordHistoryRepository, - ) -> None: - self._revisions = revisions - self._change_sets = change_sets - self._decisions = decisions - self._decision_links = decision_links - self._release_plans = release_plans - self._release_records = release_records - - def record_release( - self, - *, - release_plan: ReleasePlan, - version_resolution: VersionResolution, - authorization: ContractOpsAuthorization, - applied_release: AppliedContractRelease, - ) -> ReleaseRecord: - """Persist the exact proposal → plan → released-revision audit chain. - - All supplied ContractOps artifacts must already exist as valid outputs from - their owning M2 stages. This use case validates linkage only; it never reruns - governance, version authority, authorization, or APPLY. - """ - decision = self._decisions.get_decision(release_plan.decision_id) - change_set = self._change_sets.get_change_set(release_plan.change_set_id) - candidate_revision = self._revisions.get_revision(release_plan.release_revision_ref) - - _require_decision_link( - self._decision_links, - change_set_id=change_set.change_set_id, - decision_id=decision.decision_id, - ) - validate_contract_revision_identity(candidate_revision) - validate_release_context( - decision, - change_set, - release_plan, - version_resolution, - ) - _validate_candidate_revision(candidate_revision, release_plan, version_resolution) - _validate_apply_authorization( - authorization, - release_plan=release_plan, - version_resolution=version_resolution, - ) - _validate_applied_release( - applied_release, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - ) - - released_revision = build_contract_revision(applied_release.to_contract()) - if released_revision.revision_id == candidate_revision.revision_id: - raise ValueError( - "released revision must differ from candidate revision after APPLY versioning" - ) - - record_id = compute_release_record_id( - contract_id=release_plan.contract_id, - contract_version=version_resolution.selected_version, - decision_id=release_plan.decision_id, - change_set_id=release_plan.change_set_id, - release_plan_id=release_plan.release_plan_id, - version_resolution_id=version_resolution.version_resolution_id, - authorization_id=authorization.authorization_id, - applied_release_id=applied_release.applied_release_id, - released_revision_id=released_revision.revision_id, - required_version_bump=version_resolution.required_version_bump, - actual_version_bump=version_resolution.actual_bump, - version_authority=version_resolution.authority.value, - authority_reference=version_resolution.authority_reference, - review_evidence_reference=authorization.evidence_reference, - review_evidence_action=( - authorization.evidence_action.value - if authorization.evidence_action is not None - else None - ), - ) - record = ReleaseRecord( - release_record_id=record_id, - contract_id=release_plan.contract_id, - contract_version=version_resolution.selected_version, - decision_id=release_plan.decision_id, - change_set_id=release_plan.change_set_id, - release_plan_id=release_plan.release_plan_id, - version_resolution_id=version_resolution.version_resolution_id, - authorization_id=authorization.authorization_id, - applied_release_id=applied_release.applied_release_id, - released_revision_id=released_revision.revision_id, - required_version_bump=version_resolution.required_version_bump, - actual_version_bump=version_resolution.actual_bump, - version_authority=version_resolution.authority, - authority_reference=version_resolution.authority_reference, - review_evidence_reference=authorization.evidence_reference, - review_evidence_action=authorization.evidence_action, - ) - - # Persist referenced artifacts before the final record so a partial failure - # cannot leave a ReleaseRecord pointing at absent release/revision history. - self._release_plans.put_release_plan(release_plan) - self._revisions.put_revision(released_revision) - self._release_records.put_release_record(record) - return record - - -def _require_decision_link( - repository: ChangeSetDecisionLinkHistoryRepository, - *, - change_set_id: str, - decision_id: str, -) -> None: - links = repository.list_change_set_decision_links(change_set_id) - if not any(link.decision_id == decision_id for link in links): - raise ValueError( - "release history requires the ChangeSet-to-GovernanceDecision history link" - ) - - -def _validate_candidate_revision( - candidate_revision, - release_plan: ReleasePlan, - version_resolution: VersionResolution, -) -> None: - contract_id = str(candidate_revision.contract.id or "").strip() - contract_version = str(candidate_revision.contract.version or "").strip() - if contract_id != release_plan.contract_id: - raise ValueError("candidate ContractRevision does not match ReleasePlan contract") - if contract_version != version_resolution.current_version: - raise ValueError( - "candidate ContractRevision version does not match VersionResolution current_version" - ) - - -def _validate_apply_authorization( - authorization: ContractOpsAuthorization, - *, - release_plan: ReleasePlan, - version_resolution: VersionResolution, -) -> None: - validate_contractops_authorization_identity(authorization) - if authorization.operation is not GovernanceOperation.APPLY: - raise ValueError("release history requires APPLY-scoped authorization") - if not authorization.allowed: - raise ValueError("release history requires an allowed APPLY authorization") - if ( - authorization.decision_id != release_plan.decision_id - or authorization.change_set_id != release_plan.change_set_id - or authorization.release_plan_id != release_plan.release_plan_id - or authorization.version_resolution_id != version_resolution.version_resolution_id - ): - raise ValueError("APPLY authorization does not match the exact release context") - - -def _validate_applied_release( - applied_release: AppliedContractRelease, - *, - release_plan: ReleasePlan, - version_resolution: VersionResolution, - authorization: ContractOpsAuthorization, -) -> None: - validate_applied_release_identity(applied_release) - if ( - applied_release.contract_id != release_plan.contract_id - or applied_release.decision_id != release_plan.decision_id - or applied_release.change_set_id != release_plan.change_set_id - or applied_release.release_plan_id != release_plan.release_plan_id - or applied_release.version_resolution_id != version_resolution.version_resolution_id - or applied_release.release_revision_ref != release_plan.release_revision_ref - or applied_release.selected_version != version_resolution.selected_version - or applied_release.authorization_id != authorization.authorization_id - ): - raise ValueError("AppliedContractRelease does not match the exact release context") From e4b58fbcc8e253b1ae68bc31a5c37205b09b3d34 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:55:23 +1000 Subject: [PATCH 375/616] refactor(history): remove obsolete git operational history --- .../application/services/runtime_history.py | 207 ------------------ 1 file changed, 207 deletions(-) delete mode 100644 semapact/application/services/runtime_history.py diff --git a/semapact/application/services/runtime_history.py b/semapact/application/services/runtime_history.py deleted file mode 100644 index ce059a1d..00000000 --- a/semapact/application/services/runtime_history.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Application orchestration for point-in-time runtime evidence history.""" - -from __future__ import annotations - -from semapact.history import ( - DeploymentRecord, - DeploymentRecordHistoryRepository, - ReleaseRecord, - ReleaseRecordHistoryRepository, - RuntimeObservationHistoryRepository, - RuntimeObservationRecord, - RuntimeReconciliationHistoryRepository, - RuntimeReconciliationRecord, -) -from semapact.history.integrity import ( - compute_runtime_observation_record_id, - compute_runtime_reconciliation_record_id, -) -from semapact.observation import ( - ObservedPlatformState, - fingerprint_observed_state, - with_observed_state_fingerprint, -) -from semapact.reconciliation import ( - ReconciliationResult, - classify_reconciliation_status, -) - - -class RuntimeHistoryService: - """Persist canonical M1 runtime evidence with optional exact lifecycle links.""" - - def __init__( - self, - *, - observations: RuntimeObservationHistoryRepository, - reconciliations: RuntimeReconciliationHistoryRepository, - releases: ReleaseRecordHistoryRepository, - deployments: DeploymentRecordHistoryRepository, - ) -> None: - self._observations = observations - self._reconciliations = reconciliations - self._releases = releases - self._deployments = deployments - - def record_reconciliation( - self, - observation: ObservedPlatformState, - result: ReconciliationResult, - *, - release_record_id: str | None = None, - deployment_record_id: str | None = None, - ) -> RuntimeReconciliationRecord: - """Record one canonical observation/result pair without recomputing M1 semantics.""" - if not isinstance(observation, ObservedPlatformState): - raise TypeError( - "observation must be ObservedPlatformState, " - f"got {type(observation).__name__}" - ) - if not isinstance(result, ReconciliationResult): - raise TypeError( - f"result must be ReconciliationResult, got {type(result).__name__}" - ) - - canonical_observation = _canonical_observation(observation) - _validate_observation_result_link(canonical_observation, result) - release = _load_optional_release(self._releases, release_record_id) - deployment = _load_optional_deployment( - self._deployments, - deployment_record_id, - ) - _validate_optional_history_links( - canonical_observation, - result, - release=release, - deployment=deployment, - ) - - observation_record_id = compute_runtime_observation_record_id(canonical_observation) - observation_record = RuntimeObservationRecord( - observation_record_id=observation_record_id, - observation=canonical_observation, - ) - - status = classify_reconciliation_status(result) - runtime_reconciliation_record_id = compute_runtime_reconciliation_record_id( - observation_record_id=observation_record_id, - result=result, - status=status.value, - release_record_id=( - release.release_record_id if release is not None else None - ), - deployment_record_id=( - deployment.deployment_record_id if deployment is not None else None - ), - ) - record = RuntimeReconciliationRecord( - runtime_reconciliation_record_id=runtime_reconciliation_record_id, - observation_record_id=observation_record_id, - result=result, - status=status, - release_record_id=( - release.release_record_id if release is not None else None - ), - deployment_record_id=( - deployment.deployment_record_id if deployment is not None else None - ), - ) - - # Persist the canonical observation first so the final linkage record never - # points at absent runtime evidence after a partial storage failure. - self._observations.put_runtime_observation_record(observation_record) - self._reconciliations.put_runtime_reconciliation_record(record) - return record - - -def _canonical_observation(observation: ObservedPlatformState) -> ObservedPlatformState: - semantic_fingerprint = fingerprint_observed_state(observation) - if ( - observation.fingerprint is not None - and observation.fingerprint != semantic_fingerprint - ): - raise ValueError( - "ObservedPlatformState fingerprint does not match canonical semantic content" - ) - if observation.fingerprint is not None: - return observation - return with_observed_state_fingerprint(observation) - - -def _validate_observation_result_link( - observation: ObservedPlatformState, - result: ReconciliationResult, -) -> None: - if observation.source_identifier != result.observation_source_identifier: - raise ValueError( - "ReconciliationResult source does not match ObservedPlatformState" - ) - if observation.fingerprint != result.observation_fingerprint: - raise ValueError( - "ReconciliationResult fingerprint does not match ObservedPlatformState" - ) - - -def _load_optional_release( - repository: ReleaseRecordHistoryRepository, - release_record_id: str | None, -) -> ReleaseRecord | None: - if release_record_id is None: - return None - return repository.get_release_record(_required_text(release_record_id, "release_record_id")) - - -def _load_optional_deployment( - repository: DeploymentRecordHistoryRepository, - deployment_record_id: str | None, -) -> DeploymentRecord | None: - if deployment_record_id is None: - return None - return repository.get_deployment_record( - _required_text(deployment_record_id, "deployment_record_id") - ) - - -def _validate_optional_history_links( - observation: ObservedPlatformState, - result: ReconciliationResult, - *, - release: ReleaseRecord | None, - deployment: DeploymentRecord | None, -) -> None: - if release is not None: - if ( - release.contract_id != result.contract_id - or release.contract_version != result.contract_version - ): - raise ValueError( - "Runtime reconciliation does not match the linked ReleaseRecord" - ) - - if deployment is None: - return - if release is None: - raise ValueError( - "deployment-linked runtime history requires an explicit ReleaseRecord link" - ) - if deployment.release_record_id != release.release_record_id: - raise ValueError( - "DeploymentRecord does not belong to the linked ReleaseRecord" - ) - if deployment.platform.strip().casefold() != observation.platform.strip().casefold(): - raise ValueError( - "DeploymentRecord platform does not match ObservedPlatformState" - ) - if deployment.source_reference != observation.source_identifier: - raise ValueError( - "DeploymentRecord source does not match ObservedPlatformState" - ) - - -def _required_text(value: str, field_name: str) -> str: - if not isinstance(value, str): - raise TypeError(f"{field_name} must be str") - cleaned = value.strip() - if not cleaned: - raise ValueError(f"{field_name} must not be empty") - return cleaned From 48a756f85033a060796d07c41efb7c8dbf89b46f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:55:27 +1000 Subject: [PATCH 376/616] refactor(history): remove obsolete git operational history --- semapact/history/integrity.py | 241 ---------------------------------- 1 file changed, 241 deletions(-) delete mode 100644 semapact/history/integrity.py diff --git a/semapact/history/integrity.py b/semapact/history/integrity.py deleted file mode 100644 index b39b0ac2..00000000 --- a/semapact/history/integrity.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Deterministic identity for history-owned audit records.""" - -from __future__ import annotations - -import uuid - -from semapact.contractops import ContractRelease -from semapact.contractops.integrity import ( - compute_contract_release_id, - validate_contract_release_identity, -) -from semapact.history.models import ( - DeploymentRecord, - ReleaseRecord, - RuntimeObservationRecord, - RuntimeReconciliationRecord, -) -from semapact.observation import ObservedPlatformState -from semapact.reconciliation import ReconciliationResult -from semapact.utils.deterministic import deterministic_uuid5 - - -SEMAPACT_RELEASE_RECORD_NAMESPACE = uuid.UUID( - "9f750d1c-8f0a-491a-b861-8e349fc351cb" -) -SEMAPACT_DEPLOYMENT_RECORD_NAMESPACE = uuid.UUID( - "93db6770-90f0-4ac5-a185-2f21e818d18e" -) -SEMAPACT_RUNTIME_OBSERVATION_RECORD_NAMESPACE = uuid.UUID( - "8a3c8c10-e7d0-4df6-8b1a-3f087ef7e8e2" -) -SEMAPACT_RUNTIME_RECONCILIATION_RECORD_NAMESPACE = uuid.UUID( - "11969598-1f11-4c03-8ab0-f99f012a5041" -) - - -def compute_release_record_id( - *, - contract_id: str, - contract_version: str, - decision_id: str, - change_set_id: str, - release_plan_id: str, - version_resolution_id: str, - authorization_id: str, - applied_release_id: str, - released_revision_id: str, - required_version_bump: str, - actual_version_bump: str, - version_authority: str, - authority_reference: str | None, - review_evidence_reference: str | None, - review_evidence_action: str | None, -) -> str: - """Derive one stable identity from the complete immutable release audit record.""" - return deterministic_uuid5( - SEMAPACT_RELEASE_RECORD_NAMESPACE, - { - "contract_id": contract_id, - "contract_version": contract_version, - "decision_id": decision_id, - "change_set_id": change_set_id, - "release_plan_id": release_plan_id, - "version_resolution_id": version_resolution_id, - "authorization_id": authorization_id, - "applied_release_id": applied_release_id, - "released_revision_id": released_revision_id, - "required_version_bump": required_version_bump, - "actual_version_bump": actual_version_bump, - "version_authority": version_authority, - "authority_reference": authority_reference, - "review_evidence_reference": review_evidence_reference, - "review_evidence_action": review_evidence_action, - }, - ) - - -def validate_release_record_identity(record: ReleaseRecord) -> None: - """Fail closed when a persisted release record ID does not match its content.""" - expected = compute_release_record_id( - contract_id=record.contract_id, - contract_version=record.contract_version, - decision_id=record.decision_id, - change_set_id=record.change_set_id, - release_plan_id=record.release_plan_id, - version_resolution_id=record.version_resolution_id, - authorization_id=record.authorization_id, - applied_release_id=record.applied_release_id, - released_revision_id=record.released_revision_id, - required_version_bump=record.required_version_bump, - actual_version_bump=record.actual_version_bump, - version_authority=record.version_authority.value, - authority_reference=record.authority_reference, - review_evidence_reference=record.review_evidence_reference, - review_evidence_action=( - record.review_evidence_action.value - if record.review_evidence_action is not None - else None - ), - ) - if record.release_record_id != expected: - raise ValueError("ReleaseRecord deterministic identity does not match its content") - - -def compute_contract_release_record_id( - **payload: object, -) -> str: - """Compatibility wrapper around canonical ContractOps release identity.""" - return compute_contract_release_id(**payload) - - -def validate_contract_release_record_identity( - record: ContractRelease, -) -> None: - """Compatibility wrapper around canonical ContractOps release validation.""" - validate_contract_release_identity(record) - - -def compute_deployment_record_id( - *, - release_record_id: str, - deployment_plan_id: str, - deployment_preview_id: str, - deployment_authorization_id: str, - platform: str, - runtime_target: str, - source_reference: str, - status: str, - started_at: str, - completed_at: str, - actor_reference: str | None, - external_reference: str | None, -) -> str: - """Derive one stable identity for a concrete deployment execution occurrence.""" - return deterministic_uuid5( - SEMAPACT_DEPLOYMENT_RECORD_NAMESPACE, - { - "release_record_id": release_record_id, - "deployment_plan_id": deployment_plan_id, - "deployment_preview_id": deployment_preview_id, - "deployment_authorization_id": deployment_authorization_id, - "platform": platform, - "runtime_target": runtime_target, - "source_reference": source_reference, - "status": status, - "started_at": started_at, - "completed_at": completed_at, - "actor_reference": actor_reference, - "external_reference": external_reference, - }, - ) - - -def validate_deployment_record_identity(record: DeploymentRecord) -> None: - """Fail closed when a deployment occurrence ID does not match its content.""" - expected = compute_deployment_record_id( - release_record_id=record.release_record_id, - deployment_plan_id=record.deployment_plan_id, - deployment_preview_id=record.deployment_preview_id, - deployment_authorization_id=record.deployment_authorization_id, - platform=record.platform, - runtime_target=record.runtime_target, - source_reference=record.source_reference, - status=record.status.value, - started_at=record.started_at.isoformat(), - completed_at=record.completed_at.isoformat(), - actor_reference=record.actor_reference, - external_reference=record.external_reference, - ) - if record.deployment_record_id != expected: - raise ValueError( - "DeploymentRecord deterministic identity does not match its content" - ) - - -def compute_runtime_observation_record_id( - observation: ObservedPlatformState, -) -> str: - """Derive stable history identity from the exact canonical M1 observation.""" - if not isinstance(observation, ObservedPlatformState): - raise TypeError( - "observation must be ObservedPlatformState, " - f"got {type(observation).__name__}" - ) - return deterministic_uuid5( - SEMAPACT_RUNTIME_OBSERVATION_RECORD_NAMESPACE, - {"observation": observation.model_dump(mode="json")}, - ) - - -def validate_runtime_observation_record_identity( - record: RuntimeObservationRecord, -) -> None: - """Fail closed when observation history identity does not match exact evidence.""" - expected = compute_runtime_observation_record_id(record.observation) - if record.observation_record_id != expected: - raise ValueError( - "RuntimeObservationRecord deterministic identity does not match content" - ) - - -def compute_runtime_reconciliation_record_id( - *, - observation_record_id: str, - result: ReconciliationResult, - status: str, - release_record_id: str | None, - deployment_record_id: str | None, -) -> str: - """Derive stable identity for one persisted runtime reconciliation conclusion.""" - if not isinstance(result, ReconciliationResult): - raise TypeError( - f"result must be ReconciliationResult, got {type(result).__name__}" - ) - return deterministic_uuid5( - SEMAPACT_RUNTIME_RECONCILIATION_RECORD_NAMESPACE, - { - "observation_record_id": observation_record_id, - "result": result.model_dump(mode="json"), - "status": status, - "release_record_id": release_record_id, - "deployment_record_id": deployment_record_id, - }, - ) - - -def validate_runtime_reconciliation_record_identity( - record: RuntimeReconciliationRecord, -) -> None: - """Fail closed when runtime reconciliation history identity is stale/tampered.""" - expected = compute_runtime_reconciliation_record_id( - observation_record_id=record.observation_record_id, - result=record.result, - status=record.status.value, - release_record_id=record.release_record_id, - deployment_record_id=record.deployment_record_id, - ) - if record.runtime_reconciliation_record_id != expected: - raise ValueError( - "RuntimeReconciliationRecord deterministic identity does not match content" - ) From efb91abfdad14bf7611cffd95d04b4554bf60ec6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:55:30 +1000 Subject: [PATCH 377/616] refactor(history): remove obsolete git operational history --- tests/test_deployment_history.py | 285 ------------------------------- 1 file changed, 285 deletions(-) delete mode 100644 tests/test_deployment_history.py diff --git a/tests/test_deployment_history.py b/tests/test_deployment_history.py deleted file mode 100644 index db5c2123..00000000 --- a/tests/test_deployment_history.py +++ /dev/null @@ -1,285 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -from pathlib import Path - -import pytest - -from semapact.application.services.deployment_history import DeploymentHistoryService -from semapact.contractops import VersionAuthority -from semapact.deployment import ( - DeploymentAuthorization, - DeploymentPlan, - DeploymentPreview, - DeploymentTarget, -) -from semapact.deployment.models import ( - compute_deployment_authorization_id, - compute_deployment_plan_id, - compute_deployment_preview_id, -) -from semapact.history import ( - DeploymentStatus, - HistoryCorruptionError, - ReleaseRecord, -) -from semapact.history.integrity import compute_release_record_id -from semapact.platforms.git import GitWorkingTreeHistoryRepository - - -def _release_record() -> ReleaseRecord: - fields = { - "contract_id": "orders-product", - "contract_version": "1.3.0", - "decision_id": "decision-1", - "change_set_id": "changeset-1", - "release_plan_id": "release-plan-1", - "version_resolution_id": "version-resolution-1", - "authorization_id": "apply-authorization-1", - "applied_release_id": "applied-release-1", - "released_revision_id": "released-revision-1", - "required_version_bump": "minor", - "actual_version_bump": "minor", - "version_authority": VersionAuthority.SEMAPACT, - "authority_reference": None, - "review_evidence_reference": None, - "review_evidence_action": None, - } - release_record_id = compute_release_record_id( - **{ - **fields, - "version_authority": fields["version_authority"].value, - } - ) - return ReleaseRecord(release_record_id=release_record_id, **fields) - - -def _deployment_artifacts(*, preview_runtime_target: str = "catalog.schema", allowed: bool = True): - target = DeploymentTarget( - platform="databricks", - runtime_target="catalog.schema", - source_reference="workspace:test", - ) - plan_id = compute_deployment_plan_id( - source_snapshot_id="applied-release-1", - contract_id="orders-product", - revision_ref="candidate-revision-1", - contract_version="1.3.0", - target=target, - actions=(), - ) - plan = DeploymentPlan( - deployment_plan_id=plan_id, - source_snapshot_id="applied-release-1", - contract_id="orders-product", - revision_ref="candidate-revision-1", - contract_version="1.3.0", - target=target, - actions=(), - ) - - preview_id = compute_deployment_preview_id( - deployment_plan_id=plan.deployment_plan_id, - platform="databricks", - runtime_target=preview_runtime_target, - source_identifier="workspace:test", - observation_fingerprint="observation-1", - operations=(), - ) - preview = DeploymentPreview( - deployment_preview_id=preview_id, - deployment_plan_id=plan.deployment_plan_id, - platform="databricks", - runtime_target=preview_runtime_target, - source_identifier="workspace:test", - observation_fingerprint="observation-1", - operations=(), - ) - - authorization_id = compute_deployment_authorization_id( - authorization_kind="contractops", - authorization_reference="deploy-authorization-1", - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=plan.source_snapshot_id, - allowed=allowed, - ) - authorization = DeploymentAuthorization( - deployment_authorization_id=authorization_id, - deployment_plan_id=plan.deployment_plan_id, - source_snapshot_id=plan.source_snapshot_id, - allowed=allowed, - authorization_kind="contractops", - authorization_reference="deploy-authorization-1", - ) - return plan, preview, authorization - - -def _service(tmp_path: Path): - backend = GitWorkingTreeHistoryRepository(tmp_path) - backend.put_release_record(_release_record()) - service = DeploymentHistoryService( - releases=backend, - deployment_plans=backend, - deployment_previews=backend, - deployment_authorizations=backend, - deployment_records=backend, - ) - return service, backend - - -def test_records_terminal_deployment_occurrence(tmp_path: Path) -> None: - service, backend = _service(tmp_path) - plan, preview, authorization = _deployment_artifacts() - started = datetime(2026, 9, 13, 0, 0, tzinfo=timezone.utc) - completed = started + timedelta(seconds=12) - - record = service.record_execution( - plan=plan, - preview=preview, - authorization=authorization, - status=DeploymentStatus.SUCCEEDED, - started_at=started, - completed_at=completed, - actor_reference="agent:deploy", - external_reference="pipeline:42", - ) - - assert backend.get_deployment_plan(plan.deployment_plan_id) == plan - assert backend.get_deployment_preview(preview.deployment_preview_id) == preview - assert ( - backend.get_deployment_authorization(authorization.deployment_authorization_id) - == authorization - ) - assert backend.get_deployment_record(record.deployment_record_id) == record - assert backend.list_deployment_records_for_release(record.release_record_id) == ( - record, - ) - assert backend.list_deployment_records_for_plan(plan.deployment_plan_id) == (record,) - assert record.platform == "databricks" - assert record.runtime_target == "catalog.schema" - assert record.source_reference == "workspace:test" - assert record.status is DeploymentStatus.SUCCEEDED - - -def test_exact_deployment_history_write_is_idempotent(tmp_path: Path) -> None: - service, backend = _service(tmp_path) - plan, preview, authorization = _deployment_artifacts() - started = datetime(2026, 9, 13, 0, 0, tzinfo=timezone.utc) - completed = started + timedelta(seconds=1) - - first = service.record_execution( - plan=plan, - preview=preview, - authorization=authorization, - status=DeploymentStatus.SUCCEEDED, - started_at=started, - completed_at=completed, - ) - second = service.record_execution( - plan=plan, - preview=preview, - authorization=authorization, - status=DeploymentStatus.SUCCEEDED, - started_at=started, - completed_at=completed, - ) - - assert second == first - assert backend.list_deployment_records_for_plan(plan.deployment_plan_id) == (first,) - - -def test_failed_attempt_remains_after_later_success(tmp_path: Path) -> None: - service, backend = _service(tmp_path) - plan, preview, authorization = _deployment_artifacts() - started = datetime(2026, 9, 13, 0, 0, tzinfo=timezone.utc) - - failed = service.record_execution( - plan=plan, - preview=preview, - authorization=authorization, - status=DeploymentStatus.FAILED, - started_at=started, - completed_at=started + timedelta(seconds=2), - external_reference="pipeline:42", - ) - succeeded = service.record_execution( - plan=plan, - preview=preview, - authorization=authorization, - status=DeploymentStatus.SUCCEEDED, - started_at=started + timedelta(minutes=5), - completed_at=started + timedelta(minutes=5, seconds=3), - external_reference="pipeline:43", - ) - - records = backend.list_deployment_records_for_plan(plan.deployment_plan_id) - assert set(records) == {failed, succeeded} - assert {record.status for record in records} == { - DeploymentStatus.FAILED, - DeploymentStatus.SUCCEEDED, - } - - -def test_rejects_preview_for_different_runtime_target(tmp_path: Path) -> None: - service, _ = _service(tmp_path) - plan, preview, authorization = _deployment_artifacts( - preview_runtime_target="other.schema" - ) - - with pytest.raises(ValueError, match="runtime target"): - service.record_execution( - plan=plan, - preview=preview, - authorization=authorization, - status=DeploymentStatus.FAILED, - started_at=datetime(2026, 9, 13, tzinfo=timezone.utc), - completed_at=datetime(2026, 9, 13, 0, 0, 1, tzinfo=timezone.utc), - ) - - -def test_rejects_denied_deployment_authorization(tmp_path: Path) -> None: - service, _ = _service(tmp_path) - plan, preview, authorization = _deployment_artifacts(allowed=False) - - with pytest.raises(ValueError, match="allowed"): - service.record_execution( - plan=plan, - preview=preview, - authorization=authorization, - status=DeploymentStatus.FAILED, - started_at=datetime(2026, 9, 13, tzinfo=timezone.utc), - completed_at=datetime(2026, 9, 13, 0, 0, 1, tzinfo=timezone.utc), - ) - - -def test_rejects_naive_execution_timestamps(tmp_path: Path) -> None: - service, _ = _service(tmp_path) - plan, preview, authorization = _deployment_artifacts() - - with pytest.raises(ValueError, match="timezone-aware"): - service.record_execution( - plan=plan, - preview=preview, - authorization=authorization, - status=DeploymentStatus.SUCCEEDED, - started_at=datetime(2026, 9, 13), - completed_at=datetime(2026, 9, 13, 0, 0, 1), - ) - - -def test_tampered_deployment_record_identity_fails_closed(tmp_path: Path) -> None: - service, backend = _service(tmp_path) - plan, preview, authorization = _deployment_artifacts() - started = datetime(2026, 9, 13, tzinfo=timezone.utc) - record = service.record_execution( - plan=plan, - preview=preview, - authorization=authorization, - status=DeploymentStatus.SUCCEEDED, - started_at=started, - completed_at=started + timedelta(seconds=1), - ) - - tampered = record.model_copy(update={"status": DeploymentStatus.FAILED}) - with pytest.raises(HistoryCorruptionError, match="invalid"): - backend.put_deployment_record(tampered) From 482818e4057e88c99cc33c3c520f558aa4c80976 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:55:33 +1000 Subject: [PATCH 378/616] refactor(history): remove obsolete git operational history --- tests/test_release_history.py | 302 ---------------------------------- 1 file changed, 302 deletions(-) delete mode 100644 tests/test_release_history.py diff --git a/tests/test_release_history.py b/tests/test_release_history.py deleted file mode 100644 index 45424ec0..00000000 --- a/tests/test_release_history.py +++ /dev/null @@ -1,302 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from open_data_contract_standard.model import ( - OpenDataContractStandard, - SchemaObject, - SchemaProperty, -) - -from semapact.application.services.governance import GovernanceService -from semapact.application.services.history import ProposalHistoryService -from semapact.application.services.release_history import ReleaseHistoryService -from semapact.contractops import ( - ReviewAuthorizationEvidence, - ReviewEvidenceAction, - VersionAuthorityConfig, - apply_contract_release, - authorize_contract_operation, - build_release_plan, - resolve_release_version, -) -from semapact.governance.gate import GovernanceOperation -from semapact.history import HistoryConflictError -from semapact.platforms.git import GitWorkingTreeHistoryRepository -from semapact.revision import build_contract_revision - - -def _contract(*, include_note: bool = False) -> OpenDataContractStandard: - properties = [ - SchemaProperty( - name="id", - logicalType="string", - physicalType="varchar(255)", - required=True, - ) - ] - if include_note: - properties.append( - SchemaProperty( - name="note", - logicalType="string", - physicalType="varchar(255)", - required=False, - ) - ) - - return OpenDataContractStandard( - apiVersion="v3.1.0", - kind="DataContract", - id="orders-product", - name="Orders", - version="1.2.3", - status="active", - schema=[ - SchemaObject( - name="orders", - properties=properties, - ) - ], - ) - - -def _services(tmp_path: Path): - backend = GitWorkingTreeHistoryRepository(tmp_path) - proposal_history = ProposalHistoryService( - revisions=backend, - change_sets=backend, - decisions=backend, - decision_links=backend, - ) - release_history = ReleaseHistoryService( - revisions=backend, - change_sets=backend, - decisions=backend, - decision_links=backend, - release_plans=backend, - release_records=backend, - ) - return proposal_history, release_history, backend - - -def _release_bundle(*, source: str): - base_revision = build_contract_revision(_contract()) - candidate_revision = build_contract_revision(_contract(include_note=True)) - proposal = GovernanceService().evaluate_proposal( - base_revision.contract, - candidate_revision.contract, - effective_date="2026-09-12", - base_revision_ref=base_revision.revision_id, - candidate_revision_ref=candidate_revision.revision_id, - source=source, - actor_reference=f"actor:{source}", - ) - release_plan = build_release_plan(proposal.change_set, proposal.decision) - version_resolution = resolve_release_version( - release_plan, - current_version="1.2.3", - config=VersionAuthorityConfig(), - ) - apply_review = ReviewAuthorizationEvidence( - evidence_reference=f"review:{source}", - decision_id=proposal.decision.decision_id, - change_set_id=proposal.change_set.change_set_id, - release_plan_id=release_plan.release_plan_id, - version_resolution_id=version_resolution.version_resolution_id, - operation=GovernanceOperation.APPLY, - action=ReviewEvidenceAction.APPROVE, - ) - authorization = authorize_contract_operation( - proposal.decision, - proposal.change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - evidence=apply_review, - ) - applied_release = apply_contract_release( - candidate_revision.contract, - candidate_revision_ref=candidate_revision.revision_id, - decision=proposal.decision, - change_set=proposal.change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - ) - return ( - proposal, - base_revision, - candidate_revision, - release_plan, - version_resolution, - authorization, - applied_release, - ) - - -def _record_bundle(tmp_path: Path, *, source: str = "test"): - proposal_history, release_history, backend = _services(tmp_path) - ( - proposal, - base_revision, - candidate_revision, - release_plan, - version_resolution, - authorization, - applied_release, - ) = _release_bundle(source=source) - proposal_history.record_proposal( - proposal, - base_revision=base_revision, - candidate_revision=candidate_revision, - ) - record = release_history.record_release( - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - applied_release=applied_release, - ) - return ( - record, - proposal, - candidate_revision, - release_plan, - version_resolution, - authorization, - applied_release, - release_history, - backend, - ) - - -def test_records_release_against_exact_plan_and_released_revision(tmp_path: Path) -> None: - ( - record, - proposal, - candidate_revision, - release_plan, - version_resolution, - authorization, - applied_release, - _, - backend, - ) = _record_bundle(tmp_path) - - assert backend.get_release_plan(release_plan.release_plan_id) == release_plan - assert backend.get_release_record(record.release_record_id) == record - assert ( - backend.get_release_record_by_version( - "orders-product", - version_resolution.selected_version, - ) - == record - ) - released_revision = backend.get_revision(record.released_revision_id) - assert released_revision.revision_id != candidate_revision.revision_id - assert str(released_revision.contract.version) == version_resolution.selected_version - assert record.decision_id == proposal.decision.decision_id - assert record.change_set_id == proposal.change_set.change_set_id - assert record.version_resolution_id == version_resolution.version_resolution_id - assert record.authorization_id == authorization.authorization_id - assert record.applied_release_id == applied_release.applied_release_id - assert record.required_version_bump == version_resolution.required_version_bump - assert record.actual_version_bump == version_resolution.actual_bump - assert record.version_authority == version_resolution.authority - assert record.review_evidence_reference == authorization.evidence_reference - assert record.review_evidence_action == authorization.evidence_action - - -def test_exact_release_history_write_is_idempotent(tmp_path: Path) -> None: - ( - first, - _, - _, - release_plan, - version_resolution, - authorization, - applied_release, - release_history, - backend, - ) = _record_bundle(tmp_path) - - second = release_history.record_release( - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - applied_release=applied_release, - ) - - assert second == first - assert backend.list_release_records("orders-product") == (first,) - - -def test_conflicting_release_for_same_contract_version_fails_closed(tmp_path: Path) -> None: - _record_bundle(tmp_path, source="first") - proposal_history, release_history, _ = _services(tmp_path) - ( - proposal, - base_revision, - candidate_revision, - release_plan, - version_resolution, - authorization, - applied_release, - ) = _release_bundle(source="second") - proposal_history.record_proposal( - proposal, - base_revision=base_revision, - candidate_revision=candidate_revision, - ) - - with pytest.raises(HistoryConflictError, match="version"): - release_history.record_release( - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - applied_release=applied_release, - ) - - -def test_release_history_rejects_wrong_apply_authorization(tmp_path: Path) -> None: - proposal_history, release_history, _ = _services(tmp_path) - ( - proposal, - base_revision, - candidate_revision, - release_plan, - version_resolution, - _, - applied_release, - ) = _release_bundle(source="release") - proposal_history.record_proposal( - proposal, - base_revision=base_revision, - candidate_revision=candidate_revision, - ) - publish_review = ReviewAuthorizationEvidence( - evidence_reference="review:publish", - decision_id=proposal.decision.decision_id, - change_set_id=proposal.change_set.change_set_id, - release_plan_id=release_plan.release_plan_id, - version_resolution_id=version_resolution.version_resolution_id, - operation=GovernanceOperation.PUBLISH, - action=ReviewEvidenceAction.APPROVE, - ) - publish_authorization = authorize_contract_operation( - proposal.decision, - proposal.change_set, - release_plan, - version_resolution, - GovernanceOperation.PUBLISH, - evidence=publish_review, - ) - - with pytest.raises(ValueError, match="APPLY"): - release_history.record_release( - release_plan=release_plan, - version_resolution=version_resolution, - authorization=publish_authorization, - applied_release=applied_release, - ) From 8245ef32877a12198bf67b48230c16e16336a003 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:55:36 +1000 Subject: [PATCH 379/616] refactor(history): remove obsolete git operational history --- tests/test_runtime_history.py | 287 ---------------------------------- 1 file changed, 287 deletions(-) delete mode 100644 tests/test_runtime_history.py diff --git a/tests/test_runtime_history.py b/tests/test_runtime_history.py deleted file mode 100644 index 8172b4fb..00000000 --- a/tests/test_runtime_history.py +++ /dev/null @@ -1,287 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -from pathlib import Path - -import pytest - -from semapact.application.services.runtime_history import RuntimeHistoryService -from semapact.contractops import VersionAuthority -from semapact.history import DeploymentRecord, DeploymentStatus, ReleaseRecord -from semapact.history.integrity import compute_deployment_record_id, compute_release_record_id -from semapact.observation import ( - ObservedAsset, - ObservedAssetIdentity, - ObservedPlatformState, - fingerprint_observed_state, - with_observed_state_fingerprint, -) -from semapact.platforms.git import GitWorkingTreeHistoryRepository -from semapact.reconciliation import ( - ReconciliationDifference, - ReconciliationDifferenceType, - ReconciliationResult, - ReconciliationSubject, - RuntimeDriftStatus, - RuntimeReasonCode, -) - - -def _raw_observation( - at: datetime, - *, - asset_type: str = "TABLE", - source: str = "workspace:test", -) -> ObservedPlatformState: - return ObservedPlatformState( - platform="databricks", - source_identifier=source, - assets=( - ObservedAsset( - identity=ObservedAssetIdentity( - platform="databricks", - namespace=("catalog", "schema"), - asset="orders", - ), - asset_type=asset_type, - ), - ), - captured_at=at, - ) - - -def _observation(at: datetime, *, asset_type: str = "TABLE", source: str = "workspace:test") -> ObservedPlatformState: - return with_observed_state_fingerprint( - _raw_observation(at, asset_type=asset_type, source=source) - ) - - -def _result(observation: ObservedPlatformState, *, drift: bool = False) -> ReconciliationResult: - differences = () - if drift: - differences = ( - ReconciliationDifference( - difference_type=ReconciliationDifferenceType.MISMATCH, - subject=ReconciliationSubject.PHYSICAL_TYPE, - reason_code=RuntimeReasonCode.RUNTIME_PHYSICAL_TYPE_CHANGED, - path="orders.id.physical_type", - asset_identity="orders", - property_identity="id", - expected="STRING", - observed="BIGINT", - ), - ) - assert observation.fingerprint is not None - return ReconciliationResult( - contract_id="orders-product", - contract_version="1.3.0", - observation_source_identifier=observation.source_identifier, - observation_fingerprint=observation.fingerprint, - differences=differences, - ) - - -def _release() -> ReleaseRecord: - fields = dict( - contract_id="orders-product", - contract_version="1.3.0", - decision_id="decision-1", - change_set_id="changeset-1", - release_plan_id="release-plan-1", - version_resolution_id="version-resolution-1", - authorization_id="apply-authorization-1", - applied_release_id="applied-release-1", - released_revision_id="released-revision-1", - required_version_bump="minor", - actual_version_bump="minor", - version_authority=VersionAuthority.SEMAPACT, - authority_reference=None, - review_evidence_reference=None, - review_evidence_action=None, - ) - record_id = compute_release_record_id( - **{**fields, "version_authority": fields["version_authority"].value} - ) - return ReleaseRecord(release_record_id=record_id, **fields) - - -def _deployment(release_id: str, *, source: str = "workspace:test") -> DeploymentRecord: - started = datetime(2026, 9, 13, tzinfo=timezone.utc) - completed = started + timedelta(seconds=1) - fields = dict( - release_record_id=release_id, - deployment_plan_id="deployment-plan-1", - deployment_preview_id="deployment-preview-1", - deployment_authorization_id="deployment-authorization-1", - platform="databricks", - runtime_target="catalog.schema", - source_reference=source, - status=DeploymentStatus.SUCCEEDED, - started_at=started, - completed_at=completed, - actor_reference=None, - external_reference=None, - ) - record_id = compute_deployment_record_id( - release_record_id=release_id, - deployment_plan_id=fields["deployment_plan_id"], - deployment_preview_id=fields["deployment_preview_id"], - deployment_authorization_id=fields["deployment_authorization_id"], - platform=fields["platform"], - runtime_target=fields["runtime_target"], - source_reference=fields["source_reference"], - status=fields["status"].value, - started_at=started.isoformat(), - completed_at=completed.isoformat(), - actor_reference=None, - external_reference=None, - ) - return DeploymentRecord(deployment_record_id=record_id, **fields) - - -def _service(tmp_path: Path): - backend = GitWorkingTreeHistoryRepository(tmp_path) - return RuntimeHistoryService( - observations=backend, - reconciliations=backend, - releases=backend, - deployments=backend, - ), backend - - -def test_round_trip_and_idempotency(tmp_path: Path) -> None: - service, backend = _service(tmp_path) - observation = _observation(datetime(2026, 9, 13, 1, tzinfo=timezone.utc)) - result = _result(observation) - - first = service.record_reconciliation(observation, result) - second = service.record_reconciliation(observation, result) - - assert first == second - assert backend.get_runtime_observation_record(first.observation_record_id).observation == observation - assert backend.get_runtime_reconciliation_record(first.runtime_reconciliation_record_id) == first - assert first.status is RuntimeDriftStatus.IN_SYNC - - -def test_missing_fingerprint_is_materialized_before_history_persistence(tmp_path: Path) -> None: - service, backend = _service(tmp_path) - observation = _raw_observation(datetime(2026, 9, 13, 1, tzinfo=timezone.utc)) - semantic_fingerprint = fingerprint_observed_state(observation) - result = ReconciliationResult( - contract_id="orders-product", - contract_version="1.3.0", - observation_source_identifier=observation.source_identifier, - observation_fingerprint=semantic_fingerprint, - ) - - record = service.record_reconciliation(observation, result) - persisted = backend.get_runtime_observation_record(record.observation_record_id) - - assert observation.fingerprint is None - assert persisted.observation.fingerprint == semantic_fingerprint - - -def test_stale_materialized_fingerprint_is_rejected(tmp_path: Path) -> None: - service, _ = _service(tmp_path) - observation = _raw_observation(datetime(2026, 9, 13, 1, tzinfo=timezone.utc)).model_copy( - update={"fingerprint": "stale"} - ) - result = ReconciliationResult( - contract_id="orders-product", - contract_version="1.3.0", - observation_source_identifier=observation.source_identifier, - observation_fingerprint=fingerprint_observed_state(observation), - ) - - with pytest.raises(ValueError, match="canonical semantic content"): - service.record_reconciliation(observation, result) - - -def test_same_semantic_state_at_different_times_keeps_both_observations(tmp_path: Path) -> None: - service, backend = _service(tmp_path) - first_observation = _observation(datetime(2026, 9, 13, 1, tzinfo=timezone.utc)) - second_observation = _observation(datetime(2026, 9, 13, 2, tzinfo=timezone.utc)) - - first = service.record_reconciliation(first_observation, _result(first_observation)) - second = service.record_reconciliation(second_observation, _result(second_observation)) - - assert first_observation.fingerprint == second_observation.fingerprint - assert first.observation_record_id != second.observation_record_id - assert len(backend.list_runtime_observation_records("workspace:test")) == 2 - - -def test_sync_then_drift_coexist(tmp_path: Path) -> None: - service, backend = _service(tmp_path) - sync_observation = _observation(datetime(2026, 9, 13, 1, tzinfo=timezone.utc)) - drift_observation = _observation( - datetime(2026, 9, 13, 2, tzinfo=timezone.utc), - asset_type="VIEW", - ) - - sync = service.record_reconciliation(sync_observation, _result(sync_observation)) - drift = service.record_reconciliation(drift_observation, _result(drift_observation, drift=True)) - - records = backend.list_runtime_reconciliation_records_for_source("workspace:test") - assert set(records) == {sync, drift} - assert {item.status for item in records} == {RuntimeDriftStatus.IN_SYNC, RuntimeDriftStatus.DRIFT} - - -def test_exact_release_and_deployment_links_are_optional(tmp_path: Path) -> None: - service, backend = _service(tmp_path) - release = _release() - deployment = _deployment(release.release_record_id) - backend.put_release_record(release) - backend.put_deployment_record(deployment) - observation = _observation(datetime(2026, 9, 13, 1, tzinfo=timezone.utc)) - - record = service.record_reconciliation( - observation, - _result(observation), - release_record_id=release.release_record_id, - deployment_record_id=deployment.deployment_record_id, - ) - - assert record.release_record_id == release.release_record_id - assert record.deployment_record_id == deployment.deployment_record_id - - -def test_deployment_success_can_coexist_with_later_drift(tmp_path: Path) -> None: - service, backend = _service(tmp_path) - release = _release() - deployment = _deployment(release.release_record_id) - backend.put_release_record(release) - backend.put_deployment_record(deployment) - observation = _observation( - datetime(2026, 9, 13, 2, tzinfo=timezone.utc), - asset_type="VIEW", - ) - - record = service.record_reconciliation( - observation, - _result(observation, drift=True), - release_record_id=release.release_record_id, - deployment_record_id=deployment.deployment_record_id, - ) - - assert deployment.status is DeploymentStatus.SUCCEEDED - assert record.status is RuntimeDriftStatus.DRIFT - - -def test_rejects_mismatched_runtime_evidence(tmp_path: Path) -> None: - service, backend = _service(tmp_path) - observation = _observation(datetime(2026, 9, 13, 1, tzinfo=timezone.utc)) - wrong_result = _result(observation).model_copy(update={"observation_fingerprint": "wrong"}) - with pytest.raises(ValueError, match="fingerprint"): - service.record_reconciliation(observation, wrong_result) - - release = _release() - deployment = _deployment(release.release_record_id, source="workspace:other") - backend.put_release_record(release) - backend.put_deployment_record(deployment) - with pytest.raises(ValueError, match="source"): - service.record_reconciliation( - observation, - _result(observation), - release_record_id=release.release_record_id, - deployment_record_id=deployment.deployment_record_id, - ) From f30fdee96c2caeb764860a058a744436ea86ecb1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:57:26 +1000 Subject: [PATCH 380/616] fix(contractops): restore canonical version validation --- semapact/contractops/execution.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/semapact/contractops/execution.py b/semapact/contractops/execution.py index 5c9a1f3c..c30443b9 100644 --- a/semapact/contractops/execution.py +++ b/semapact/contractops/execution.py @@ -93,3 +93,17 @@ def build_release_snapshot( selected_version=selected_version, released_contract_json=released_contract_json, ) + + +def _canonical_version(version: str, *, field_name: str) -> str: + try: + canonical = normalize_semver(version) + except ValueError as exc: + raise ReleaseValidationError( + f"{field_name} is not valid semantic version" + ) from exc + if canonical != str(version).strip(): + raise ReleaseValidationError( + f"{field_name} must use canonical major.minor.patch form" + ) + return canonical From 4b4d03ec01190c0c8715d18060b915bebb359ac2 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:57:45 +1000 Subject: [PATCH 381/616] test(contractops): cover canonical release snapshot only --- tests/test_contractops_execution.py | 348 +++------------------------- 1 file changed, 34 insertions(+), 314 deletions(-) diff --git a/tests/test_contractops_execution.py b/tests/test_contractops_execution.py index 4872d4b7..8ec60106 100644 --- a/tests/test_contractops_execution.py +++ b/tests/test_contractops_execution.py @@ -11,20 +11,14 @@ from semapact.change_context import ChangeContext from semapact.contractops import ( - ReviewAuthorizationEvidence, - ReviewEvidenceAction, VersionAuthorityConfig, - apply_contract_release, - authorize_contract_operation, build_change_set_from_decision, build_release_plan, build_release_snapshot, - publish_contract_release, resolve_release_version, ) -from semapact.exceptions import ContractOpsAuthorizationError, ReleaseValidationError -from semapact.governance import DecisionResult, evaluate_governance_decision -from semapact.governance.gate import GovernanceOperation +from semapact.exceptions import ReleaseValidationError +from semapact.governance import evaluate_governance_decision CONTEXT = ChangeContext(effective_date=date(2026, 9, 9)) @@ -65,15 +59,12 @@ def _contract( ) -def _release_context(kind: str): +def _release_context(*, include_created_at: bool = False): base = _contract(contract_name="orders-old") - if kind == "allow": - candidate = _contract(contract_name="orders-new") - elif kind == "review": - candidate = _contract(contract_name="orders-old", include_created_at=True) - else: # pragma: no cover - test helper guard - raise ValueError(kind) - + candidate = _contract( + contract_name="orders-new", + include_created_at=include_created_at, + ) decision = evaluate_governance_decision(base, candidate, context=CONTEXT) change_set = build_change_set_from_decision( decision, @@ -91,64 +82,9 @@ def _release_context(kind: str): return candidate, decision, change_set, release_plan, version_resolution -def _review_evidence( - decision, - change_set, - release_plan, - version_resolution, - operation: GovernanceOperation, -) -> ReviewAuthorizationEvidence: - return ReviewAuthorizationEvidence( - evidence_reference=f"approval:{operation.value.lower()}", - decision_id=decision.decision_id, - change_set_id=change_set.change_set_id, - release_plan_id=release_plan.release_plan_id, - version_resolution_id=version_resolution.version_resolution_id, - operation=operation, - action=ReviewEvidenceAction.APPROVE, - ) - - -def _authorization( - decision, - change_set, - release_plan, - version_resolution, - operation: GovernanceOperation, - *, - approve_review: bool = True, -): - evidence = None - if decision.decision is DecisionResult.REVIEW and approve_review: - evidence = _review_evidence( - decision, - change_set, - release_plan, - version_resolution, - operation, - ) - return authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - operation, - evidence=evidence, - ) - - -class RecordingPublisher: - def __init__(self) -> None: - self.calls = [] - - def publish(self, release) -> str: - self.calls.append(release) - return f"published:{release.applied_release_id}" - - -def test_release_snapshot_is_pure_and_requires_no_apply_authorization() -> None: - candidate, decision, change_set, release_plan, version_resolution = _release_context( - "review" +def test_release_snapshot_is_deterministic_and_materializes_selected_version() -> None: + candidate, decision, change_set, release_plan, version_resolution = ( + _release_context(include_created_at=True) ) first = build_release_snapshot( @@ -170,277 +106,61 @@ def test_release_snapshot_is_pure_and_requires_no_apply_authorization() -> None: assert first == second assert first.release_snapshot_id == second.release_snapshot_id - assert first.selected_version == "1.1.0" - assert first.to_contract().version == "1.1.0" - assert candidate.version == "1.0.0" - assert not hasattr(first, "authorization_id") - - -def test_apply_metadata_only_release_uses_selected_patch_without_mutating_candidate() -> None: - candidate, decision, change_set, release_plan, version_resolution = _release_context( - "allow" - ) - authorization = _authorization( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - ) - - assert decision.decision is DecisionResult.ALLOW - assert release_plan.required_version_bump == "none" - assert version_resolution.selected_version == "1.0.1" - - first = apply_contract_release( - candidate, - candidate_revision_ref="rev:candidate", - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - ) - second = apply_contract_release( - candidate, - candidate_revision_ref="rev:candidate", - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - ) - - assert first == second - assert first.applied_release_id == second.applied_release_id - assert first.selected_version == "1.0.1" - assert first.release_revision_ref == "rev:candidate" - assert first.to_contract().version == "1.0.1" + assert first.selected_version == version_resolution.selected_version + assert first.to_contract().version == version_resolution.selected_version assert first.to_contract().name == "orders-new" assert candidate.version == "1.0.0" + assert not hasattr(first, "authorization_id") -def test_authorized_review_can_apply_without_rewriting_decision() -> None: - candidate, decision, change_set, release_plan, version_resolution = _release_context( - "review" - ) - authorization = _authorization( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - ) - - release = apply_contract_release( - candidate, - candidate_revision_ref="rev:candidate", - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - ) - - assert decision.decision is DecisionResult.REVIEW - assert authorization.allowed is True - assert release.selected_version == "1.1.0" - assert release.to_contract().version == "1.1.0" - assert len(release.to_contract().schema_[0].properties) == 2 - - -def test_review_without_approval_cannot_apply() -> None: - candidate, decision, change_set, release_plan, version_resolution = _release_context( - "review" - ) - authorization = _authorization( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - approve_review=False, - ) - - assert authorization.allowed is False - with pytest.raises(ContractOpsAuthorizationError, match="not authorized"): - apply_contract_release( - candidate, - candidate_revision_ref="rev:candidate", - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - ) - - -def test_apply_fails_closed_for_stale_candidate_revision() -> None: - candidate, decision, change_set, release_plan, version_resolution = _release_context( - "allow" - ) - authorization = _authorization( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, +def test_release_snapshot_fails_closed_for_stale_candidate_revision() -> None: + candidate, decision, change_set, release_plan, version_resolution = ( + _release_context() ) with pytest.raises(ReleaseValidationError, match="planned release revision"): - apply_contract_release( + build_release_snapshot( candidate, candidate_revision_ref="rev:stale", decision=decision, change_set=change_set, release_plan=release_plan, version_resolution=version_resolution, - authorization=authorization, ) -def test_apply_fails_closed_when_candidate_version_drifted() -> None: - candidate, decision, change_set, release_plan, version_resolution = _release_context( - "allow" - ) - authorization = _authorization( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, +def test_release_snapshot_fails_closed_when_candidate_version_drifted() -> None: + candidate, decision, change_set, release_plan, version_resolution = ( + _release_context() ) drifted_candidate = candidate.model_copy(deep=True) drifted_candidate.version = "9.0.0" with pytest.raises(ReleaseValidationError, match="current_version"): - apply_contract_release( + build_release_snapshot( drifted_candidate, candidate_revision_ref="rev:candidate", decision=decision, change_set=change_set, release_plan=release_plan, version_resolution=version_resolution, - authorization=authorization, ) -def test_apply_authorization_cannot_authorize_publish() -> None: - candidate, decision, change_set, release_plan, version_resolution = _release_context( - "allow" - ) - apply_authorization = _authorization( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - ) - release = apply_contract_release( - candidate, - candidate_revision_ref="rev:candidate", - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=apply_authorization, - ) - publisher = RecordingPublisher() - - with pytest.raises(ReleaseValidationError, match="PUBLISH authorization"): - publish_contract_release( - release, - authorization=apply_authorization, - publisher=publisher, - ) - - assert publisher.calls == [] - - -def test_publish_invokes_adapter_only_after_exact_publish_authorization() -> None: - candidate, decision, change_set, release_plan, version_resolution = _release_context( - "allow" - ) - apply_authorization = _authorization( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - ) - publish_authorization = _authorization( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.PUBLISH, - ) - release = apply_contract_release( - candidate, - candidate_revision_ref="rev:candidate", - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=apply_authorization, - ) - publisher = RecordingPublisher() - - first = publish_contract_release( - release, - authorization=publish_authorization, - publisher=publisher, - ) - second = publish_contract_release( - release, - authorization=publish_authorization, - publisher=RecordingPublisher(), - ) - - assert len(publisher.calls) == 1 - assert publisher.calls[0] == release - assert first == second - assert first.publication_id == second.publication_id - assert first.applied_release_id == release.applied_release_id - assert first.authorization_id == publish_authorization.authorization_id - assert first.publication_reference == f"published:{release.applied_release_id}" - - -def test_review_without_publish_approval_never_invokes_publisher() -> None: - candidate, decision, change_set, release_plan, version_resolution = _release_context( - "review" +def test_release_snapshot_rejects_noncanonical_semver_input() -> None: + candidate, decision, change_set, release_plan, version_resolution = ( + _release_context() ) - apply_authorization = _authorization( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, + drifted_resolution = version_resolution.model_copy( + update={"selected_version": "v1.0.1"} ) - denied_publish_authorization = _authorization( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.PUBLISH, - approve_review=False, - ) - release = apply_contract_release( - candidate, - candidate_revision_ref="rev:candidate", - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=apply_authorization, - ) - publisher = RecordingPublisher() - with pytest.raises(ContractOpsAuthorizationError, match="not authorized"): - publish_contract_release( - release, - authorization=denied_publish_authorization, - publisher=publisher, + with pytest.raises(ReleaseValidationError, match="canonical"): + build_release_snapshot( + candidate, + candidate_revision_ref="rev:candidate", + decision=decision, + change_set=change_set, + release_plan=release_plan, + version_resolution=drifted_resolution, ) - - assert publisher.calls == [] From e840e328a07c4a2f242c1483cd2b351f6c564690 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:57:58 +1000 Subject: [PATCH 382/616] test(contractops): rehydrate canonical release artifacts only --- .../test_contractops_artifact_rehydration.py | 67 +++++++------------ 1 file changed, 25 insertions(+), 42 deletions(-) diff --git a/tests/test_contractops_artifact_rehydration.py b/tests/test_contractops_artifact_rehydration.py index 64d1690b..415a7b58 100644 --- a/tests/test_contractops_artifact_rehydration.py +++ b/tests/test_contractops_artifact_rehydration.py @@ -11,24 +11,24 @@ ) from pydantic import ValidationError as PydanticValidationError +from semapact.application.models.release import ( + build_contract_release, + build_release_bundle, +) from semapact.change_context import ChangeContext from semapact.contractops import ( - AppliedContractRelease, ChangeSet, - ContractOpsAuthorization, - PublicationResult, + ContractRelease, ReleasePlan, + ReleaseSnapshot, VersionAuthorityConfig, VersionResolution, - apply_contract_release, - authorize_contract_operation, build_change_set_from_decision, build_release_plan, - publish_contract_release, + build_release_snapshot, resolve_release_version, ) from semapact.governance import evaluate_governance_decision -from semapact.governance.gate import GovernanceOperation CONTEXT = ChangeContext(effective_date=date(2026, 9, 11)) @@ -58,11 +58,6 @@ def _contract(*, name: str) -> OpenDataContractStandard: ) -class _Publisher: - def publish(self, release: AppliedContractRelease) -> str: - return f"registry:{release.applied_release_id}" - - def _artifact_chain(): base = _contract(name="orders-old") candidate = _contract(name="orders-new") @@ -80,41 +75,28 @@ def _artifact_chain(): current_version="1.0.0", config=VersionAuthorityConfig(), ) - apply_authorization = authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - ) - applied_release = apply_contract_release( + snapshot = build_release_snapshot( candidate, candidate_revision_ref="git:candidate", decision=decision, change_set=change_set, release_plan=release_plan, version_resolution=version_resolution, - authorization=apply_authorization, ) - publish_authorization = authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.PUBLISH, - ) - publication = publish_contract_release( - applied_release, - authorization=publish_authorization, - publisher=_Publisher(), + bundle = build_release_bundle( + decision=decision, + change_set=change_set, + release_plan=release_plan, + version_resolution=version_resolution, + release_snapshot=snapshot, ) + contract_release = build_contract_release(bundle) return ( change_set, release_plan, version_resolution, - apply_authorization, - applied_release, - publication, + snapshot, + contract_release, ) @@ -131,23 +113,24 @@ def test_rehydration_rejects_content_with_stale_deterministic_identity() -> None change_set, release_plan, version_resolution, - authorization, - applied_release, - publication, + snapshot, + contract_release, ) = _artifact_chain() cases = ( (ChangeSet, change_set, "candidate_revision_ref", "git:other"), (ReleasePlan, release_plan, "release_revision_ref", "git:other"), (VersionResolution, version_resolution, "release_revision_ref", "git:other"), - (ContractOpsAuthorization, authorization, "decision_id", "decision:other"), - (AppliedContractRelease, applied_release, "decision_id", "decision:other"), - (PublicationResult, publication, "publication_reference", "registry:other"), + (ReleaseSnapshot, snapshot, "decision_id", "decision:other"), + (ContractRelease, contract_release, "source_revision_ref", "git:other"), ) for model, artifact, field, tampered_value in cases: payload = artifact.model_dump(mode="json") payload[field] = tampered_value persisted_json = json.dumps(payload, separators=(",", ":"), sort_keys=True) - with pytest.raises(PydanticValidationError, match="deterministic identity"): + with pytest.raises( + (PydanticValidationError, ValueError), + match="deterministic identity|does not match", + ): model.model_validate_json(persisted_json) From 6c7476e3cfe690ecc6cc3f94e0e4cd0a8da5d3fb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:58:17 +1000 Subject: [PATCH 383/616] test(architecture): assert canonical owners only --- tests/test_architecture_consolidation.py | 226 ++++++++--------------- 1 file changed, 75 insertions(+), 151 deletions(-) diff --git a/tests/test_architecture_consolidation.py b/tests/test_architecture_consolidation.py index 6a27e5ef..87f78916 100644 --- a/tests/test_architecture_consolidation.py +++ b/tests/test_architecture_consolidation.py @@ -4,42 +4,30 @@ import uuid from datetime import date -import pytest from open_data_contract_standard.model import ( OpenDataContractStandard, SchemaObject, SchemaProperty, ) +from semapact.application.services.release_workflow import ( + ReleaseFinalizer, + ReleaseWorkflowService, +) from semapact.change_context import ChangeContext from semapact.contractops import ( - AuthorizationReason, - ReviewAuthorizationEvidence, - ReviewEvidenceAction, + VersionAuthority, VersionAuthorityConfig, - apply_contract_release, - authorize_contract_operation, build_change_set_from_decision, build_release_plan, resolve_release_version, ) -from semapact.core.release import ( - classify_contract_change as legacy_classify_contract_change, - normalize_semver as legacy_normalize_semver, -) from semapact.deployment import ( DeploymentTarget, - authorize_deployment, - build_deployment_plan, -) -from semapact.exceptions import ReleaseValidationError -from semapact.governance import DecisionResult, evaluate_governance_decision -from semapact.governance.change_classification import classify_contract_change -from semapact.governance.gate import GovernanceOperation -from semapact.observation import RuntimeAssetSpec as ObservationRuntimeAssetSpec -from semapact.reconciliation.binding import ( - runtime_asset_specs_from_contract as legacy_runtime_asset_specs_from_contract, + build_contract_release_deployment_source, + build_deployment_plan_from_source, ) +from semapact.governance import evaluate_governance_decision from semapact.runtime import RuntimeAssetSpec, runtime_asset_specs_from_contract from semapact.utils.deterministic import canonical_compact_json, deterministic_uuid5 from semapact.versioning import normalize_semver @@ -48,7 +36,11 @@ CONTEXT = ChangeContext(effective_date=date(2026, 9, 10)) -def _contract(*, include_created_at: bool = False) -> OpenDataContractStandard: +def _contract( + *, + name: str = "Orders", + include_created_at: bool = False, +) -> OpenDataContractStandard: properties = [ SchemaProperty( name="id", @@ -70,155 +62,85 @@ def _contract(*, include_created_at: bool = False) -> OpenDataContractStandard: apiVersion="v3.1.0", kind="DataContract", id="orders-product", - name="Orders", + name=name, version="1.0.0", status="active", schema=[SchemaObject(name="orders", properties=properties)], ) -def _review_release_chain(): - base = _contract() - candidate = _contract(include_created_at=True) - decision = evaluate_governance_decision(base, candidate, context=CONTEXT) - assert decision.decision is DecisionResult.REVIEW - - change_set = build_change_set_from_decision( - decision, +def _finalized_allow_release(): + workflow = ReleaseWorkflowService() + bundle = workflow.assess( + _contract(name="Orders old"), + _contract(name="Orders new"), + effective_date="2026-09-10", base_revision_ref="rev:base", candidate_revision_ref="rev:candidate", ) - release_plan = build_release_plan(change_set, decision) - version_resolution = resolve_release_version( - release_plan, - current_version="1.0.0", - config=VersionAuthorityConfig(), - ) - apply_evidence = ReviewAuthorizationEvidence( - evidence_reference="approval:apply", - decision_id=decision.decision_id, - change_set_id=change_set.change_set_id, - release_plan_id=release_plan.release_plan_id, - version_resolution_id=version_resolution.version_resolution_id, - operation=GovernanceOperation.APPLY, - action=ReviewEvidenceAction.APPROVE, - ) - apply_authorization = authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - evidence=apply_evidence, - ) - release = apply_contract_release( - candidate, - candidate_revision_ref="rev:candidate", - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=apply_authorization, - ) - return decision, change_set, release_plan, version_resolution, release + return ReleaseFinalizer().finalize(bundle) -def test_deterministic_helper_preserves_existing_compact_uuid_formula() -> None: +def test_deterministic_helper_uses_canonical_compact_uuid_formula() -> None: namespace = uuid.UUID("3ea0f6d8-28ca-4bb4-94f5-ea1f0f48cb84") payload = {"z": "é", "a": [2, 1], "nested": {"b": True}} - - legacy_json = json.dumps( + encoded = json.dumps( payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, ) - legacy_id = str(uuid.uuid5(namespace, legacy_json)) - - assert canonical_compact_json(payload) == legacy_json - assert deterministic_uuid5(namespace, payload) == legacy_id + assert canonical_compact_json(payload) == encoded + assert deterministic_uuid5(namespace, payload) == str(uuid.uuid5(namespace, encoded)) -def test_legacy_release_imports_delegate_to_canonical_owners() -> None: - assert legacy_normalize_semver is normalize_semver - assert legacy_classify_contract_change is classify_contract_change - assert legacy_normalize_semver("v1.2.3") == "1.2.3" - - -def test_reconciliation_asset_projection_remains_compatible_but_neutral() -> None: - assert legacy_runtime_asset_specs_from_contract is runtime_asset_specs_from_contract - assert ObservationRuntimeAssetSpec is RuntimeAssetSpec +def test_runtime_asset_projection_has_one_canonical_owner() -> None: specs = runtime_asset_specs_from_contract(_contract()) - assert specs == (RuntimeAssetSpec(governed_asset="orders", physical_name="orders"),) + + assert specs == ( + RuntimeAssetSpec(governed_asset="orders", physical_name="orders"), + ) -def test_review_deploy_authorization_is_bound_to_exact_deployment_plan() -> None: - decision, change_set, release_plan, version_resolution, release = _review_release_chain() - production_plan = build_deployment_plan( - release, +def test_finalized_release_projects_to_target_specific_canonical_plan() -> None: + release = _finalized_allow_release() + source = build_contract_release_deployment_source(release) + production = build_deployment_plan_from_source( + source, DeploymentTarget( platform="databricks", runtime_target="main.production", source_reference="https://production-workspace.example", ), ) - - evidence = ReviewAuthorizationEvidence( - evidence_reference="approval:deploy-production", - decision_id=decision.decision_id, - change_set_id=change_set.change_set_id, - release_plan_id=release_plan.release_plan_id, - version_resolution_id=version_resolution.version_resolution_id, - operation=GovernanceOperation.DEPLOY, - action=ReviewEvidenceAction.APPROVE, - scope_reference=production_plan.deployment_plan_id, - ) - contractops_authorization = authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.DEPLOY, - evidence=evidence, - ) - - assert contractops_authorization.allowed is True - assert contractops_authorization.reason is AuthorizationReason.ALLOWED_BY_REVIEW - assert contractops_authorization.scope_reference == production_plan.deployment_plan_id - - deployment_authorization = authorize_deployment( - production_plan, - release, - contractops_authorization, - ) - assert deployment_authorization.allowed is True - assert deployment_authorization.deployment_plan_id == production_plan.deployment_plan_id - - staging_plan = build_deployment_plan( - release, + staging = build_deployment_plan_from_source( + source, DeploymentTarget( platform="databricks", runtime_target="main.staging", source_reference="https://staging-workspace.example", ), ) - with pytest.raises(ReleaseValidationError, match="not scoped to this DeploymentPlan"): - authorize_deployment(staging_plan, release, contractops_authorization) + assert production.source_snapshot_id == source.source_snapshot_id + assert staging.source_snapshot_id == source.source_snapshot_id + assert production.deployment_plan_id != staging.deployment_plan_id -def test_same_runtime_namespace_on_another_source_requires_distinct_plan() -> None: - _, _, _, _, release = _review_release_chain() - workspace_a = build_deployment_plan( - release, + +def test_same_runtime_namespace_on_another_source_has_distinct_plan_identity() -> None: + release = _finalized_allow_release() + source = build_contract_release_deployment_source(release) + workspace_a = build_deployment_plan_from_source( + source, DeploymentTarget( platform="databricks", runtime_target="main.production", source_reference="https://workspace-a.example", ), ) - workspace_b = build_deployment_plan( - release, + workspace_b = build_deployment_plan_from_source( + source, DeploymentTarget( platform="databricks", runtime_target="main.production", @@ -229,33 +151,35 @@ def test_same_runtime_namespace_on_another_source_requires_distinct_plan() -> No assert workspace_a.deployment_plan_id != workspace_b.deployment_plan_id -def test_publish_authorization_cannot_be_reused_for_runtime_deploy() -> None: - decision, change_set, release_plan, version_resolution, release = _review_release_chain() - plan = build_deployment_plan( - release, - DeploymentTarget( - platform="databricks", - runtime_target="main.production", - source_reference="https://production-workspace.example", - ), +def test_version_authorities_preserve_required_bump() -> None: + base = _contract() + candidate = _contract(include_created_at=True) + decision = evaluate_governance_decision(base, candidate, context=CONTEXT) + change_set = build_change_set_from_decision( + decision, + base_revision_ref="rev:base", + candidate_revision_ref="rev:candidate", ) - evidence = ReviewAuthorizationEvidence( - evidence_reference="approval:publish", - decision_id=decision.decision_id, - change_set_id=change_set.change_set_id, - release_plan_id=release_plan.release_plan_id, - version_resolution_id=version_resolution.version_resolution_id, - operation=GovernanceOperation.PUBLISH, - action=ReviewEvidenceAction.APPROVE, + release_plan = build_release_plan(change_set, decision) + + semapact_resolution = resolve_release_version( + release_plan, + current_version="1.0.0", + config=VersionAuthorityConfig(authority=VersionAuthority.SEMAPACT), ) - publish_authorization = authorize_contract_operation( - decision, - change_set, + git_resolution = resolve_release_version( release_plan, - version_resolution, - GovernanceOperation.PUBLISH, - evidence=evidence, + current_version="1.0.0", + config=VersionAuthorityConfig( + authority=VersionAuthority.GIT, + tag_pattern="v{version}", + ), + authority_reference="v1.2.0", ) - with pytest.raises(ReleaseValidationError, match="DEPLOY authorization"): - authorize_deployment(plan, release, publish_authorization) + assert decision.required_version_bump == "minor" + assert semapact_resolution.required_version_bump == "minor" + assert git_resolution.required_version_bump == "minor" + assert semapact_resolution.selected_version == "1.1.0" + assert git_resolution.selected_version == "1.2.0" + assert normalize_semver("v1.2.3") == "1.2.3" From d3c56ff9466827d8e63534d445ef55987b61745c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:58:34 +1000 Subject: [PATCH 384/616] test(contractops): keep golden scenarios canonical --- tests/test_contractops_golden_scenarios.py | 557 +++------------------ 1 file changed, 66 insertions(+), 491 deletions(-) diff --git a/tests/test_contractops_golden_scenarios.py b/tests/test_contractops_golden_scenarios.py index b694cc2b..bc5c8e86 100644 --- a/tests/test_contractops_golden_scenarios.py +++ b/tests/test_contractops_golden_scenarios.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import date, datetime, timezone -from types import SimpleNamespace import pytest from open_data_contract_standard.model import ( @@ -10,53 +9,34 @@ SchemaProperty, ) +from semapact.application.services.release_workflow import ( + ReleaseFinalizer, + ReleaseWorkflowService, +) from semapact.change_context import ChangeContext from semapact.contractops import ( - ReviewAuthorizationEvidence, - ReviewEvidenceAction, VersionAuthority, VersionAuthorityConfig, - apply_contract_release, - authorize_contract_operation, build_change_set_from_decision, build_release_plan, resolve_release_version, ) from semapact.deployment import ( DeploymentTarget, - authorize_deployment, - build_deployment_plan, - verify_deployment_convergence, -) -from semapact.exceptions import ( - ContractOpsAuthorizationError, - GovernanceBlockedError, - ReleaseValidationError, - ValidationError, + build_contract_release_deployment_source, + build_deployment_plan_from_source, ) +from semapact.exceptions import ContractOpsAuthorizationError, GovernanceBlockedError from semapact.governance import DecisionResult, evaluate_governance_decision -from semapact.governance.gate import GovernanceOperation -from semapact.observation.fingerprint import with_observed_state_fingerprint -from semapact.observation.models import ( - ObservedAsset, - ObservedAssetIdentity, - ObservedPlatformState, - ObservedProperty, - ObservedPropertyIdentity, -) -from semapact.observation.providers import RuntimeAssetBinding -from semapact.platforms.databricks.deployment import DatabricksDeploymentAdapter -from semapact.reconciliation import RuntimeDriftStatus, classify_reconciliation_status CONTEXT = ChangeContext(effective_date=date(2026, 9, 11)) -CAPTURED_AT = datetime(2026, 9, 11, 6, 0, tzinfo=timezone.utc) def _contract( *, contract_id: str = "orders-product", - contract_name: str = "orders", + name: str = "orders", include_created_at: bool = False, ) -> OpenDataContractStandard: properties = [ @@ -80,96 +60,32 @@ def _contract( apiVersion="v3.1.0", kind="DataContract", id=contract_id, - name=contract_name, + name=name, version="1.0.0", status="active", - schema=[SchemaObject(name="orders", properties=properties)], + schema=[ + SchemaObject( + name="orders", + physicalName="orders", + properties=properties, + ) + ], ) -def _release_artifacts(candidate: OpenDataContractStandard): - base = _contract(contract_name="orders-old") - decision = evaluate_governance_decision(base, candidate, context=CONTEXT) - change_set = build_change_set_from_decision( - decision, +def _allow_chain(): + workflow = ReleaseWorkflowService() + bundle = workflow.assess( + _contract(name="orders-old"), + _contract(name="orders-new"), + effective_date="2026-09-11", base_revision_ref="git:base", candidate_revision_ref="git:candidate", - source="golden-test", - actor_reference="service:ci", - ) - release_plan = build_release_plan(change_set, decision) - version_resolution = resolve_release_version( - release_plan, - current_version="1.0.0", - config=VersionAuthorityConfig(), ) - return decision, change_set, release_plan, version_resolution - - -def _review_evidence( - decision, - change_set, - release_plan, - version_resolution, - *, - operation: GovernanceOperation, - scope_reference: str | None = None, -) -> ReviewAuthorizationEvidence: - return ReviewAuthorizationEvidence( - evidence_reference=f"approval:{operation.value.lower()}", - decision_id=decision.decision_id, - change_set_id=change_set.change_set_id, - release_plan_id=release_plan.release_plan_id, - version_resolution_id=version_resolution.version_resolution_id, - operation=operation, - action=ReviewEvidenceAction.APPROVE, - scope_reference=scope_reference, - ) - - -def _apply_release(candidate: OpenDataContractStandard): - decision, change_set, release_plan, version_resolution = _release_artifacts(candidate) - evidence = None - if decision.decision is DecisionResult.REVIEW: - evidence = _review_evidence( - decision, - change_set, - release_plan, - version_resolution, - operation=GovernanceOperation.APPLY, - ) - authorization = authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - evidence=evidence, - ) - release = apply_contract_release( - candidate, - candidate_revision_ref="git:candidate", - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - ) - return decision, change_set, release_plan, version_resolution, authorization, release - - -def _allow_chain(): - candidate = _contract(contract_name="orders-new") - ( - decision, - change_set, - release_plan, - version_resolution, - apply_authorization, - release, - ) = _apply_release(candidate) - deployment_plan = build_deployment_plan( - release, + release = ReleaseFinalizer().finalize(bundle) + source = build_contract_release_deployment_source(release) + plan = build_deployment_plan_from_source( + source, DeploymentTarget( platform="databricks", runtime_target="main.silver", @@ -177,321 +93,52 @@ def _allow_chain(): server_name="production", ), ) - deploy_authorization = authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.DEPLOY, - ) - deployment_authorization = authorize_deployment( - deployment_plan, - release, - deploy_authorization, - ) - return SimpleNamespace( - candidate=candidate, - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - apply_authorization=apply_authorization, - release=release, - deployment_plan=deployment_plan, - deploy_authorization=deploy_authorization, - deployment_authorization=deployment_authorization, - ) - - -def _observed_state( - *, - present: bool, - physical_type: str | None = "varchar(255)", - nullable: bool | None = False, -) -> ObservedPlatformState: - identity = ObservedAssetIdentity( - platform="databricks", - namespace=("main", "silver"), - asset="orders", - ) - assets = () - if present: - assets = ( - ObservedAsset( - identity=identity, - asset_type="MANAGED", - properties=( - ObservedProperty( - identity=ObservedPropertyIdentity( - asset=identity, - property="id", - ), - physical_type=physical_type, - nullable=nullable, - ), - ), - ), - ) - return with_observed_state_fingerprint( - ObservedPlatformState( - platform="databricks", - source_identifier="workspace:golden", - assets=assets, - captured_at=CAPTURED_AT, - fingerprint=None, - ) - ) - - -class _RuntimeProvider: - key = "databricks" - - def __init__(self, state: ObservedPlatformState) -> None: - self.state = state - self.observe_calls = 0 - - def resolve_bindings(self, *, runtime_target, assets): - assert runtime_target == "main.silver" - return tuple( - RuntimeAssetBinding( - governed_asset=asset.governed_asset, - observed_asset=ObservedAssetIdentity( - platform="databricks", - namespace=("main", "silver"), - asset=asset.physical_name, - ), - ) - for asset in assets - ) - - def observe(self, *, bindings): - self.observe_calls += 1 - return self.state - - -class _Statements: - def __init__(self) -> None: - self.calls: list[str] = [] - - def execute_statement(self, *, statement, warehouse_id, wait_timeout): - assert warehouse_id == "warehouse-golden" - self.calls.append(statement) - return SimpleNamespace( - statement_id="statement-golden", - status=SimpleNamespace(state="SUCCEEDED", error=None), - ) - - def get_statement(self, statement_id): - raise AssertionError(f"unexpected statement polling: {statement_id}") - - -class _Client: - def __init__(self) -> None: - self.statement_execution = _Statements() - - -def _adapter(provider: _RuntimeProvider): - client = _Client() - adapter = DatabricksDeploymentAdapter( - client=client, - runtime_provider=provider, - warehouse_id="warehouse-golden", - poll_interval_seconds=0, - ) - return adapter, client - - -def _allow_semantic_projection(chain) -> dict[str, object]: - action = chain.deployment_plan.actions[0] - return { - "decision": chain.decision.decision.value, - "requiredVersionBump": chain.decision.required_version_bump, - "changeCount": len(chain.change_set.changes), - "releasePreconditions": [ - item.value for item in chain.release_plan.preconditions - ], - "versionAuthority": chain.version_resolution.authority.value, - "selectedVersion": chain.version_resolution.selected_version, - "actualBump": chain.version_resolution.actual_bump, - "apply": { - "operation": chain.apply_authorization.operation.value, - "allowed": chain.apply_authorization.allowed, - "reason": chain.apply_authorization.reason.value, - }, - "appliedContract": { - "name": chain.release.to_contract().name, - "version": chain.release.to_contract().version, - }, - "deployment": { - "operation": chain.deploy_authorization.operation.value, - "allowed": chain.deploy_authorization.allowed, - "target": chain.deployment_plan.target.model_dump(mode="json"), - "actions": [ - { - "kind": action.kind.value, - "governedAsset": action.governed_asset, - "physicalName": action.physical_name, - } - ], - }, - } + return bundle, release, source, plan -def test_allow_chain_has_stable_cross_boundary_golden_semantics() -> None: +def test_allow_chain_has_stable_cross_boundary_semantics() -> None: first = _allow_chain() second = _allow_chain() - assert _allow_semantic_projection(first) == { - "decision": "ALLOW", - "requiredVersionBump": "none", - "changeCount": 1, - "releasePreconditions": [], - "versionAuthority": "semapact", - "selectedVersion": "1.0.1", - "actualBump": "patch", - "apply": { - "operation": "APPLY", - "allowed": True, - "reason": "allowed_by_governance", - }, - "appliedContract": {"name": "orders-new", "version": "1.0.1"}, - "deployment": { - "operation": "DEPLOY", - "allowed": True, - "target": { - "platform": "databricks", - "runtime_target": "main.silver", - "source_reference": "workspace:golden", - "server_name": "production", - }, - "actions": [ - { - "kind": "ENSURE_ASSET_STATE", - "governedAsset": "orders", - "physicalName": "orders", - } - ], - }, - } - - assert first.decision.decision_id == second.decision.decision_id - assert first.change_set.change_set_id == second.change_set.change_set_id - assert first.release_plan.release_plan_id == second.release_plan.release_plan_id - assert ( - first.version_resolution.version_resolution_id - == second.version_resolution.version_resolution_id - ) - assert first.apply_authorization.authorization_id == second.apply_authorization.authorization_id - assert first.release.applied_release_id == second.release.applied_release_id - assert ( - first.deployment_plan.deployment_plan_id - == second.deployment_plan.deployment_plan_id - ) - assert ( - first.deployment_authorization.deployment_authorization_id - == second.deployment_authorization.deployment_authorization_id - ) - assert first.candidate.version == "1.0.0" - + first_bundle, first_release, first_source, first_plan = first + second_bundle, second_release, second_source, second_plan = second -def test_review_requires_exact_apply_and_deployment_authorization() -> None: - candidate = _contract(contract_name="orders-old", include_created_at=True) - decision, change_set, release_plan, version_resolution = _release_artifacts(candidate) + assert first_bundle.decision.decision is DecisionResult.ALLOW + assert first_bundle.version_resolution.selected_version == "1.0.1" + assert first_release.contract_version == "1.0.1" + assert first_release.to_contract().name == "orders-new" + assert first_source.release_id == first_release.contract_release_id + assert first_plan.contract_version == first_release.contract_version - assert decision.decision is DecisionResult.REVIEW + assert first_bundle.bundle_digest == second_bundle.bundle_digest + assert first_release.contract_release_id == second_release.contract_release_id + assert first_source.source_snapshot_id == second_source.source_snapshot_id + assert first_plan.deployment_plan_id == second_plan.deployment_plan_id - denied_apply = authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - ) - assert denied_apply.allowed is False - with pytest.raises(ContractOpsAuthorizationError): - apply_contract_release( - candidate, - candidate_revision_ref="git:candidate", - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=denied_apply, - ) - apply_evidence = _review_evidence( - decision, - change_set, - release_plan, - version_resolution, - operation=GovernanceOperation.APPLY, - ) - apply_authorization = authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - evidence=apply_evidence, - ) - release = apply_contract_release( - candidate, +def test_review_release_requires_exact_publish_approval() -> None: + workflow = ReleaseWorkflowService() + bundle = workflow.assess( + _contract(name="orders", include_created_at=False), + _contract(name="orders", include_created_at=True), + effective_date="2026-09-11", + base_revision_ref="git:base", candidate_revision_ref="git:candidate", - decision=decision, - change_set=change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=apply_authorization, - ) - plan = build_deployment_plan( - release, - DeploymentTarget( - platform="databricks", - runtime_target="main.silver", - source_reference="workspace:golden", - ), ) + assert bundle.decision.decision is DecisionResult.REVIEW - unscoped_deploy_evidence = _review_evidence( - decision, - change_set, - release_plan, - version_resolution, - operation=GovernanceOperation.DEPLOY, - ) - unscoped_deploy = authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.DEPLOY, - evidence=unscoped_deploy_evidence, - ) - with pytest.raises(ReleaseValidationError, match="scoped"): - authorize_deployment(plan, release, unscoped_deploy) + with pytest.raises(ContractOpsAuthorizationError, match="requires approval"): + ReleaseFinalizer().finalize(bundle) - scoped_deploy_evidence = _review_evidence( - decision, - change_set, - release_plan, - version_resolution, - operation=GovernanceOperation.DEPLOY, - scope_reference=plan.deployment_plan_id, + approval = workflow.approve( + bundle, + actor_reference="human:reviewer", + recorded_at=datetime(2026, 9, 11, 8, tzinfo=timezone.utc), ) - scoped_deploy = authorize_contract_operation( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.DEPLOY, - evidence=scoped_deploy_evidence, - ) - deployment_authorization = authorize_deployment(plan, release, scoped_deploy) + release = ReleaseFinalizer().finalize(bundle, approval=approval) - assert decision.decision is DecisionResult.REVIEW - assert apply_authorization.allowed is True - assert scoped_deploy.allowed is True - assert deployment_authorization.allowed is True + assert release.contract_version == bundle.version_resolution.selected_version + assert release.release_snapshot_id == bundle.release_snapshot.release_snapshot_id def test_block_cannot_enter_release_chain() -> None: @@ -510,8 +157,15 @@ def test_block_cannot_enter_release_chain() -> None: def test_version_authorities_preserve_same_required_bump() -> None: - candidate = _contract(contract_name="orders-old", include_created_at=True) - decision, _, release_plan, _ = _release_artifacts(candidate) + base = _contract() + candidate = _contract(include_created_at=True) + decision = evaluate_governance_decision(base, candidate, context=CONTEXT) + change_set = build_change_set_from_decision( + decision, + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + ) + release_plan = build_release_plan(change_set, decision) semapact_resolution = resolve_release_version( release_plan, @@ -533,82 +187,3 @@ def test_version_authorities_preserve_same_required_bump() -> None: assert git_resolution.required_version_bump == "minor" assert semapact_resolution.selected_version == "1.1.0" assert git_resolution.selected_version == "1.2.0" - assert semapact_resolution.authority_reference is None - assert git_resolution.authority_reference == "v1.2.0" - - -def test_publish_authorization_cannot_authorize_runtime_deployment() -> None: - chain = _allow_chain() - publish_authorization = authorize_contract_operation( - chain.decision, - chain.change_set, - chain.release_plan, - chain.version_resolution, - GovernanceOperation.PUBLISH, - ) - - with pytest.raises(ReleaseValidationError, match="DEPLOY authorization"): - authorize_deployment( - chain.deployment_plan, - chain.release, - publish_authorization, - ) - - -def test_execute_success_is_separate_from_runtime_convergence() -> None: - chain = _allow_chain() - missing_state = _observed_state(present=False) - provider = _RuntimeProvider(missing_state) - adapter, client = _adapter(provider) - - first_preview = adapter.preview(chain.deployment_plan) - second_preview = adapter.preview(chain.deployment_plan) - assert first_preview == second_preview - assert client.statement_execution.calls == [] - - adapter.execute( - chain.deployment_plan, - first_preview, - chain.deployment_authorization, - ) - assert len(client.statement_execution.calls) == 1 - - statement_count = len(client.statement_execution.calls) - drift_result = verify_deployment_convergence(chain.deployment_plan, provider) - assert classify_reconciliation_status(drift_result) is RuntimeDriftStatus.DRIFT - assert len(client.statement_execution.calls) == statement_count - - provider.state = _observed_state(present=True) - in_sync_result = verify_deployment_convergence(chain.deployment_plan, provider) - assert classify_reconciliation_status(in_sync_result) is RuntimeDriftStatus.IN_SYNC - assert len(client.statement_execution.calls) == statement_count - - provider.state = _observed_state( - present=True, - physical_type=None, - nullable=None, - ) - indeterminate_result = verify_deployment_convergence(chain.deployment_plan, provider) - assert ( - classify_reconciliation_status(indeterminate_result) - is RuntimeDriftStatus.INDETERMINATE - ) - assert len(client.statement_execution.calls) == statement_count - - -def test_stale_preview_fails_before_native_mutation() -> None: - chain = _allow_chain() - missing_state = _observed_state(present=False) - provider = _RuntimeProvider(missing_state) - adapter, client = _adapter(provider) - preview = adapter.preview(chain.deployment_plan) - - provider.state = _observed_state(present=True) - with pytest.raises(ValidationError, match="no longer equals|Runtime state changed"): - adapter.execute( - chain.deployment_plan, - preview, - chain.deployment_authorization, - ) - - assert client.statement_execution.calls == [] From 7d8f61fe931ba50d2276bf6e509ec63810a90336 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:58:51 +1000 Subject: [PATCH 385/616] test(history): remove obsolete deployment history golden --- tests/test_history_golden_scenarios.py | 647 ------------------------- 1 file changed, 647 deletions(-) delete mode 100644 tests/test_history_golden_scenarios.py diff --git a/tests/test_history_golden_scenarios.py b/tests/test_history_golden_scenarios.py deleted file mode 100644 index 0ed8ee99..00000000 --- a/tests/test_history_golden_scenarios.py +++ /dev/null @@ -1,647 +0,0 @@ -from __future__ import annotations - -import json -from datetime import datetime, timedelta, timezone -from pathlib import Path -from types import SimpleNamespace - -import pytest -from open_data_contract_standard.model import ( - OpenDataContractStandard, - SchemaObject, - SchemaProperty, -) - -from semapact.application.services.deployment_history import DeploymentHistoryService -from semapact.application.services.evolution import EvolutionChainService -from semapact.application.services.governance import GovernanceService -from semapact.application.services.history import ProposalHistoryService -from semapact.application.services.history_integrity import HistoryIntegrityService -from semapact.application.services.release_history import ReleaseHistoryService -from semapact.application.services.runtime_history import RuntimeHistoryService -from semapact.contractops import ( - ReviewAuthorizationEvidence, - ReviewEvidenceAction, - VersionAuthorityConfig, - apply_contract_release, - authorize_contract_operation, - build_change_set, - build_release_plan, - resolve_release_version, -) -from semapact.deployment import ( - DeploymentAuthorization, - DeploymentPreview, - DeploymentTarget, - authorize_deployment, - build_deployment_plan, -) -from semapact.deployment.models import compute_deployment_preview_id -from semapact.governance import DecisionResult -from semapact.governance.gate import GovernanceOperation -from semapact.history import DeploymentStatus, HistoryConflictError -from semapact.observation import ( - ObservedAsset, - ObservedAssetIdentity, - ObservedPlatformState, - with_observed_state_fingerprint, -) -from semapact.platforms.git import GitWorkingTreeHistoryRepository -from semapact.reconciliation import ( - ReconciliationDifference, - ReconciliationDifferenceType, - ReconciliationResult, - ReconciliationSubject, - RuntimeDriftStatus, - RuntimeReasonCode, -) -from semapact.revision import build_contract_revision - - -GOLDEN_FIXTURE = ( - Path(__file__).parent - / "fixtures" - / "history_golden" - / "v1" - / "review_multi_deploy.json" -) -CONTRACT_ID = "orders-product" -CURRENT_VERSION = "1.2.3" -EFFECTIVE_DATE = "2026-09-15" - - -def _contract( - *, - name: str = "Orders", - version: str = CURRENT_VERSION, - include_note: bool = False, -) -> OpenDataContractStandard: - properties = [ - SchemaProperty( - name="id", - logicalType="string", - physicalType="varchar(255)", - required=True, - ) - ] - if include_note: - properties.append( - SchemaProperty( - name="note", - logicalType="string", - physicalType="varchar(255)", - required=False, - ) - ) - return OpenDataContractStandard( - apiVersion="v3.1.0", - kind="DataContract", - id=CONTRACT_ID, - name=name, - version=version, - status="active", - schema=[SchemaObject(name="orders", properties=properties)], - ) - - -def _proposal_history(backend: GitWorkingTreeHistoryRepository) -> ProposalHistoryService: - return ProposalHistoryService( - revisions=backend, - change_sets=backend, - decisions=backend, - decision_links=backend, - ) - - -def _release_history(backend: GitWorkingTreeHistoryRepository) -> ReleaseHistoryService: - return ReleaseHistoryService( - revisions=backend, - change_sets=backend, - decisions=backend, - decision_links=backend, - release_plans=backend, - release_records=backend, - ) - - -def _deployment_history(backend: GitWorkingTreeHistoryRepository) -> DeploymentHistoryService: - return DeploymentHistoryService( - releases=backend, - deployment_plans=backend, - deployment_previews=backend, - deployment_authorizations=backend, - deployment_records=backend, - ) - - -def _runtime_history(backend: GitWorkingTreeHistoryRepository) -> RuntimeHistoryService: - return RuntimeHistoryService( - observations=backend, - reconciliations=backend, - releases=backend, - deployments=backend, - ) - - -def _evolution(backend: GitWorkingTreeHistoryRepository) -> EvolutionChainService: - return EvolutionChainService( - revisions=backend, - change_sets=backend, - decisions=backend, - decision_links=backend, - releases=backend, - deployments=backend, - observations=backend, - runtime_reconciliations=backend, - ) - - -def _record_release( - root: Path, - *, - base: OpenDataContractStandard, - candidate: OpenDataContractStandard, - source: str, -): - backend = GitWorkingTreeHistoryRepository(root) - base_revision = build_contract_revision(base) - candidate_revision = build_contract_revision(candidate) - proposal = GovernanceService().evaluate_proposal( - base_revision.contract, - candidate_revision.contract, - effective_date=EFFECTIVE_DATE, - base_revision_ref=base_revision.revision_id, - candidate_revision_ref=candidate_revision.revision_id, - source=source, - actor_reference="actor:golden", - ) - _proposal_history(backend).record_proposal( - proposal, - base_revision=base_revision, - candidate_revision=candidate_revision, - ) - - release_plan = build_release_plan(proposal.change_set, proposal.decision) - version_resolution = resolve_release_version( - release_plan, - current_version=CURRENT_VERSION, - config=VersionAuthorityConfig(), - ) - evidence = None - if proposal.decision.decision is DecisionResult.REVIEW: - evidence = ReviewAuthorizationEvidence( - evidence_reference="review:apply:golden", - decision_id=proposal.decision.decision_id, - change_set_id=proposal.change_set.change_set_id, - release_plan_id=release_plan.release_plan_id, - version_resolution_id=version_resolution.version_resolution_id, - operation=GovernanceOperation.APPLY, - action=ReviewEvidenceAction.APPROVE, - ) - authorization = authorize_contract_operation( - proposal.decision, - proposal.change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - evidence=evidence, - ) - applied_release = apply_contract_release( - candidate_revision.contract, - candidate_revision_ref=candidate_revision.revision_id, - decision=proposal.decision, - change_set=proposal.change_set, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - ) - release_record = _release_history(backend).record_release( - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - applied_release=applied_release, - ) - return SimpleNamespace( - backend=backend, - base_revision=base_revision, - candidate_revision=candidate_revision, - proposal=proposal, - release_plan=release_plan, - version_resolution=version_resolution, - authorization=authorization, - applied_release=applied_release, - release_record=release_record, - ) - - -def _deployment_preview(plan, *, label: str) -> DeploymentPreview: - observation_fingerprint = f"preview-observation:{label}" - preview_id = compute_deployment_preview_id( - deployment_plan_id=plan.deployment_plan_id, - platform=plan.target.platform, - runtime_target=plan.target.runtime_target, - source_identifier=plan.target.source_reference, - observation_fingerprint=observation_fingerprint, - operations=(), - ) - return DeploymentPreview( - deployment_preview_id=preview_id, - deployment_plan_id=plan.deployment_plan_id, - platform=plan.target.platform, - runtime_target=plan.target.runtime_target, - source_identifier=plan.target.source_reference, - observation_fingerprint=observation_fingerprint, - operations=(), - ) - - -def _record_deployment( - bundle, - *, - label: str, - runtime_target: str, - source_reference: str, - status: DeploymentStatus, - started_at: datetime, -): - plan = build_deployment_plan( - bundle.applied_release, - DeploymentTarget( - platform="databricks", - runtime_target=runtime_target, - source_reference=source_reference, - server_name=label, - ), - ) - deploy_evidence = ReviewAuthorizationEvidence( - evidence_reference=f"review:deploy:{label}", - decision_id=bundle.proposal.decision.decision_id, - change_set_id=bundle.proposal.change_set.change_set_id, - release_plan_id=bundle.release_plan.release_plan_id, - version_resolution_id=bundle.version_resolution.version_resolution_id, - operation=GovernanceOperation.DEPLOY, - action=ReviewEvidenceAction.APPROVE, - scope_reference=plan.deployment_plan_id, - ) - contractops_authorization = authorize_contract_operation( - bundle.proposal.decision, - bundle.proposal.change_set, - bundle.release_plan, - bundle.version_resolution, - GovernanceOperation.DEPLOY, - evidence=deploy_evidence, - ) - deployment_authorization: DeploymentAuthorization = authorize_deployment( - plan, - bundle.applied_release, - contractops_authorization, - ) - preview = _deployment_preview(plan, label=label) - record = _deployment_history(bundle.backend).record_execution( - plan=plan, - preview=preview, - authorization=deployment_authorization, - status=status, - started_at=started_at, - completed_at=started_at + timedelta(seconds=5), - actor_reference="agent:golden-deploy", - external_reference=f"run:{label}", - ) - return SimpleNamespace( - plan=plan, - preview=preview, - authorization=deployment_authorization, - record=record, - ) - - -def _observation( - *, - captured_at: datetime, - source_reference: str, - namespace: tuple[str, str], - asset_type: str, -) -> ObservedPlatformState: - return with_observed_state_fingerprint( - ObservedPlatformState( - platform="databricks", - source_identifier=source_reference, - assets=( - ObservedAsset( - identity=ObservedAssetIdentity( - platform="databricks", - namespace=namespace, - asset="orders", - ), - asset_type=asset_type, - ), - ), - captured_at=captured_at, - ) - ) - - -def _reconciliation_result( - observation: ObservedPlatformState, - *, - version: str, - drift: bool, -) -> ReconciliationResult: - differences = () - if drift: - differences = ( - ReconciliationDifference( - difference_type=ReconciliationDifferenceType.MISMATCH, - subject=ReconciliationSubject.PHYSICAL_TYPE, - reason_code=RuntimeReasonCode.RUNTIME_PHYSICAL_TYPE_CHANGED, - path="orders.id.physical_type", - asset_identity="orders", - property_identity="id", - expected="STRING", - observed="BIGINT", - ), - ) - assert observation.fingerprint is not None - return ReconciliationResult( - contract_id=CONTRACT_ID, - contract_version=version, - observation_source_identifier=observation.source_identifier, - observation_fingerprint=observation.fingerprint, - differences=differences, - ) - - -def _record_runtime(bundle, deployment, *, captured_at: datetime, drift: bool): - namespace = tuple(deployment.record.runtime_target.split(".")) - assert len(namespace) == 2 - observation = _observation( - captured_at=captured_at, - source_reference=deployment.record.source_reference, - namespace=(namespace[0], namespace[1]), - asset_type="VIEW" if drift else "TABLE", - ) - record = _runtime_history(bundle.backend).record_reconciliation( - observation, - _reconciliation_result( - observation, - version=bundle.release_record.contract_version, - drift=drift, - ), - release_record_id=bundle.release_record.release_record_id, - deployment_record_id=deployment.record.deployment_record_id, - ) - return SimpleNamespace(observation=observation, record=record) - - -def _evolution_projection(chain) -> dict[str, object]: - proposals = [] - for proposal in chain.proposals: - releases = [] - for release in proposal.releases: - deployments = [] - for deployment in release.deployments: - deployments.append( - { - "deploymentRecordId": deployment.deployment.deployment_record_id, - "runtimeTarget": deployment.deployment.runtime_target, - "status": deployment.deployment.status.value, - "runtime": [ - { - "reconciliationRecordId": item.reconciliation.runtime_reconciliation_record_id, - "observationRecordId": item.reconciliation.observation_record_id, - "status": item.reconciliation.status.value, - } - for item in deployment.runtime - ], - } - ) - releases.append( - { - "releaseRecordId": release.release.release_record_id, - "contractVersion": release.release.contract_version, - "releasedRevisionId": release.release.released_revision_id, - "deployments": deployments, - } - ) - proposals.append( - { - "changeSetId": proposal.change_set.change_set_id, - "decisionId": proposal.decision.decision_id if proposal.decision else None, - "decision": proposal.decision.decision.value if proposal.decision else None, - "baseRevisionId": ( - proposal.base_revision.revision_id if proposal.base_revision else None - ), - "candidateRevisionId": ( - proposal.candidate_revision.revision_id - if proposal.candidate_revision - else None - ), - "releases": releases, - } - ) - return { - "contractId": chain.contract_id, - "proposals": proposals, - "unlinkedReleaseIds": [ - item.release.release_record_id for item in chain.unlinked_releases - ], - "unlinkedRuntimeIds": [ - item.reconciliation.runtime_reconciliation_record_id - for item in chain.unlinked_runtime - ], - "brokenReferences": [ - { - "sourceKind": item.source_kind, - "sourceId": item.source_id, - "referenceField": item.reference_field, - "targetKind": item.target_kind, - "targetId": item.target_id, - "reason": item.reason, - } - for item in chain.broken_references - ], - } - - -def _history_files(root: Path) -> dict[str, object]: - """Snapshot persisted schema and exact canonical content through its checksum seal.""" - history_root = root / ".semapact" / "history" - files: dict[str, object] = {} - for path in sorted(history_root.rglob("*.json"), key=Path.as_posix): - relative = path.relative_to(root).as_posix() - content = json.loads(path.read_text(encoding="utf-8")) - checksum_path = path.with_name(f"{path.name}.sha256") - files[relative] = { - "checksum": checksum_path.read_text(encoding="utf-8").strip(), - "topLevelKeys": sorted(content), - } - return files - - -def _build_review_multi_deploy(root: Path): - bundle = _record_release( - root, - base=_contract(), - candidate=_contract(include_note=True), - source="golden-review", - ) - assert bundle.proposal.decision.decision is DecisionResult.REVIEW - - start = datetime(2026, 9, 15, 0, 0, tzinfo=timezone.utc) - production = _record_deployment( - bundle, - label="production", - runtime_target="main.prod", - source_reference="workspace:prod", - status=DeploymentStatus.SUCCEEDED, - started_at=start, - ) - staging = _record_deployment( - bundle, - label="staging", - runtime_target="main.stage", - source_reference="workspace:stage", - status=DeploymentStatus.FAILED, - started_at=start + timedelta(minutes=10), - ) - sync = _record_runtime( - bundle, - production, - captured_at=start + timedelta(hours=1), - drift=False, - ) - drift = _record_runtime( - bundle, - production, - captured_at=start + timedelta(hours=2), - drift=True, - ) - assert sync.record.status is RuntimeDriftStatus.IN_SYNC - assert drift.record.status is RuntimeDriftStatus.DRIFT - - reloaded = GitWorkingTreeHistoryRepository(root) - chain = _evolution(reloaded).reconstruct(CONTRACT_ID) - assert _evolution(reloaded).reconstruct(CONTRACT_ID) == chain - integrity = HistoryIntegrityService( - storage=reloaded, - evolution=_evolution(reloaded), - ).check_contract(CONTRACT_ID) - assert integrity.valid is True - - manifest = { - "fixtureVersion": "history-v1", - "files": _history_files(root), - "evolution": _evolution_projection(chain), - } - return SimpleNamespace( - manifest=manifest, - backend=reloaded, - bundle=bundle, - production=production, - staging=staging, - sync=sync, - drift=drift, - chain=chain, - ) - - -def test_review_multi_deploy_history_matches_checked_in_v1_fixture(tmp_path: Path) -> None: - first = _build_review_multi_deploy(tmp_path / "first") - second = _build_review_multi_deploy(tmp_path / "second") - - assert first.manifest == second.manifest - expected = json.loads(GOLDEN_FIXTURE.read_text(encoding="utf-8")) - if first.manifest != expected: - pytest.fail( - "history golden fixture mismatch; update only for an intentional persisted " - "schema/identity change.\nACTUAL:\n" - + json.dumps(first.manifest, indent=2, sort_keys=True) - ) - - -def test_allow_release_reconstructs_without_review_evidence(tmp_path: Path) -> None: - bundle = _record_release( - tmp_path, - base=_contract(name="Orders old"), - candidate=_contract(name="Orders new"), - source="golden-allow", - ) - - assert bundle.proposal.decision.decision is DecisionResult.ALLOW - assert bundle.authorization.evidence_reference is None - chain = _evolution(GitWorkingTreeHistoryRepository(tmp_path)).reconstruct(CONTRACT_ID) - assert len(chain.proposals) == 1 - assert chain.proposals[0].decision is not None - assert chain.proposals[0].decision.decision is DecisionResult.ALLOW - assert len(chain.proposals[0].releases) == 1 - assert chain.proposals[0].releases[0].release == bundle.release_record - - -def test_blocked_proposal_persists_without_release(tmp_path: Path) -> None: - backend = GitWorkingTreeHistoryRepository(tmp_path) - base_revision = build_contract_revision(_contract(version=CURRENT_VERSION)) - candidate_revision = build_contract_revision(_contract(version="2.0.0")) - proposal = GovernanceService().evaluate_proposal( - base_revision.contract, - candidate_revision.contract, - effective_date=EFFECTIVE_DATE, - base_revision_ref=base_revision.revision_id, - candidate_revision_ref=candidate_revision.revision_id, - source="golden-block", - actor_reference="actor:golden", - ) - assert proposal.decision.decision is DecisionResult.BLOCK - _proposal_history(backend).record_proposal( - proposal, - base_revision=base_revision, - candidate_revision=candidate_revision, - ) - - chain = _evolution(backend).reconstruct(CONTRACT_ID) - assert len(chain.proposals) == 1 - assert chain.proposals[0].decision is not None - assert chain.proposals[0].decision.decision is DecisionResult.BLOCK - assert chain.proposals[0].releases == () - assert backend.list_release_records(CONTRACT_ID) == () - - -def test_golden_history_rejects_conflicting_overwrite(tmp_path: Path) -> None: - scenario = _build_review_multi_deploy(tmp_path) - decision = scenario.bundle.proposal.decision - conflicting = decision.model_copy(update={"contract_id": "other-contract"}) - - with pytest.raises(HistoryConflictError, match="different content"): - scenario.backend.put_decision(conflicting) - - assert scenario.backend.get_decision(decision.decision_id) == decision - - -def test_broken_reference_is_explicit_and_does_not_fabricate_release(tmp_path: Path) -> None: - bundle = _record_release( - tmp_path, - base=_contract(), - candidate=_contract(include_note=True), - source="golden-broken-base", - ) - broken = build_change_set( - contract_id=CONTRACT_ID, - base_revision_ref="missing-revision", - candidate_revision_ref=bundle.candidate_revision.revision_id, - changes=bundle.proposal.change_set.changes, - context=bundle.proposal.change_set.context, - source="golden-broken", - ) - bundle.backend.put_change_set(broken) - - chain = _evolution(bundle.backend).reconstruct(CONTRACT_ID) - broken_path = next(item for item in chain.proposals if item.change_set == broken) - assert broken_path.base_revision is None - assert broken_path.decision is None - assert broken_path.releases == () - assert any( - item.source_id == broken.change_set_id - and item.reference_field == "base_revision_ref" - and item.target_id == "missing-revision" - and item.reason == "NOT_FOUND" - for item in chain.broken_references - ) From 3bd468ad3b52cdbd562d521485d1b95b4ab22ba1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:58:54 +1000 Subject: [PATCH 386/616] test(history): remove obsolete deployment history golden --- .../v1/review_multi_deploy.json | 293 ------------------ 1 file changed, 293 deletions(-) delete mode 100644 tests/fixtures/history_golden/v1/review_multi_deploy.json diff --git a/tests/fixtures/history_golden/v1/review_multi_deploy.json b/tests/fixtures/history_golden/v1/review_multi_deploy.json deleted file mode 100644 index 0386d45f..00000000 --- a/tests/fixtures/history_golden/v1/review_multi_deploy.json +++ /dev/null @@ -1,293 +0,0 @@ -{ - "evolution": { - "brokenReferences": [], - "contractId": "orders-product", - "proposals": [ - { - "baseRevisionId": "fb437fde-4baa-5dad-a382-c4df381ba412", - "candidateRevisionId": "352d83a9-4ee6-508a-981f-4bc9819263c9", - "changeSetId": "c2f4ee02-90e5-532a-ac8a-edd43e49f156", - "decision": "REVIEW", - "decisionId": "14295d1b-b52d-5d8b-8dad-2121529f099c", - "releases": [ - { - "contractVersion": "1.3.0", - "deployments": [ - { - "deploymentRecordId": "2f2c8752-0abf-5d19-b097-af057b4a0fe1", - "runtime": [], - "runtimeTarget": "main.stage", - "status": "FAILED" - }, - { - "deploymentRecordId": "7fe4b74d-604c-58ec-94ba-f469651ea25a", - "runtime": [ - { - "observationRecordId": "38695939-4fb5-583c-acfa-1a21397d89c8", - "reconciliationRecordId": "1e5b9772-02f3-5461-9a7b-a8cf73f6a609", - "status": "DRIFT" - }, - { - "observationRecordId": "e259c151-972f-532a-99d1-a14dcd78b166", - "reconciliationRecordId": "7d8fcefb-3470-590b-ba48-1f1f9910be85", - "status": "IN_SYNC" - } - ], - "runtimeTarget": "main.prod", - "status": "SUCCEEDED" - } - ], - "releaseRecordId": "de7c4ed0-d54a-5a7a-b82e-c626eab39d23", - "releasedRevisionId": "290bd707-017f-56ca-a379-c8879b9ede13" - } - ] - } - ], - "unlinkedReleaseIds": [], - "unlinkedRuntimeIds": [] - }, - "files": { - ".semapact/history/change_set_decisions/c2f4ee02-90e5-532a-ac8a-edd43e49f156/14295d1b-b52d-5d8b-8dad-2121529f099c.json": { - "checksum": "sha256:d77324f66e332dfdd6cb3ff081e65987a6eaa469d5b84bab2cb425d55e23dd62", - "topLevelKeys": [ - "change_set_id", - "decision_id" - ] - }, - ".semapact/history/change_sets/c2f4ee02-90e5-532a-ac8a-edd43e49f156.json": { - "checksum": "sha256:66861c622ee7f42f4340a761d17c16a006bf738ad682c39359c552376ce95005", - "topLevelKeys": [ - "actor_reference", - "base_revision_ref", - "candidate_revision_ref", - "change_set_id", - "changes", - "context", - "contract_id", - "source" - ] - }, - ".semapact/history/contract_revisions/290bd707-017f-56ca-a379-c8879b9ede13.json": { - "checksum": "sha256:e70f5404098efaf4cec40f9a021adbba17cad53ed0c57db6984126fdcb84373e", - "topLevelKeys": [ - "content_fingerprint", - "contract", - "revision_id" - ] - }, - ".semapact/history/contract_revisions/352d83a9-4ee6-508a-981f-4bc9819263c9.json": { - "checksum": "sha256:bf1e3e1836fff7827bc014caccfefd36a01aaf480ecd17eae41c9a8245e5eb9e", - "topLevelKeys": [ - "content_fingerprint", - "contract", - "revision_id" - ] - }, - ".semapact/history/contract_revisions/fb437fde-4baa-5dad-a382-c4df381ba412.json": { - "checksum": "sha256:be036b579fe23c3d47afb96978c1e05416564b5ad0e30df034803c2c86bbf9c2", - "topLevelKeys": [ - "content_fingerprint", - "contract", - "revision_id" - ] - }, - ".semapact/history/decisions/14295d1b-b52d-5d8b-8dad-2121529f099c.json": { - "checksum": "sha256:5c0ddb33910feb477c32252d21c318e273e031185f52f4295aa0074dd78bf485", - "topLevelKeys": [ - "breaking", - "changes", - "context", - "contract_id", - "decision", - "decision_id", - "evidence", - "policy", - "reasons", - "required_version_bump", - "validation" - ] - }, - ".semapact/history/deployment_authorizations/2b383d08-7583-54dc-bd1a-2303a34ed38c.json": { - "checksum": "sha256:285a824c976aef723f1d77fcb406aefa13751511afc1be09f74cea8c90415130", - "topLevelKeys": [ - "allowed", - "authorization_kind", - "authorization_reference", - "authorization_version", - "deployment_authorization_id", - "deployment_plan_id", - "source_snapshot_id" - ] - }, - ".semapact/history/deployment_authorizations/e0c54b6a-f14a-5584-9134-9da2cd299ba3.json": { - "checksum": "sha256:a6d2770299e8676b38d467752ae32ac1b51d08add31bc0d5557ab60a4a193496", - "topLevelKeys": [ - "allowed", - "authorization_kind", - "authorization_reference", - "authorization_version", - "deployment_authorization_id", - "deployment_plan_id", - "source_snapshot_id" - ] - }, - ".semapact/history/deployment_plans/71cabdc5-f6df-549f-810b-58546a767986.json": { - "checksum": "sha256:a42942b9107e36b3eb0bc57f67196f45f615dd636a122c33d005b05a6480c4a7", - "topLevelKeys": [ - "actions", - "contract_id", - "contract_version", - "deployment_plan_id", - "plan_version", - "revision_ref", - "source_snapshot_id", - "target" - ] - }, - ".semapact/history/deployment_plans/bb2bddd8-ffc4-5cb2-bb50-5c963bc86772.json": { - "checksum": "sha256:423b6f8354fea81e1714366a79857b901ab423c713e6939952dcb05e757a11ca", - "topLevelKeys": [ - "actions", - "contract_id", - "contract_version", - "deployment_plan_id", - "plan_version", - "revision_ref", - "source_snapshot_id", - "target" - ] - }, - ".semapact/history/deployment_previews/50f0e4ff-e23c-5b19-a823-8c87c8557766.json": { - "checksum": "sha256:5946711e28c3910a23dca2bda3b5f7fa76722da2c3ba4b838f75837b4ca0f926", - "topLevelKeys": [ - "deployment_plan_id", - "deployment_preview_id", - "observation_fingerprint", - "operations", - "platform", - "preview_version", - "runtime_target", - "source_identifier" - ] - }, - ".semapact/history/deployment_previews/f3d22787-d634-50c7-b640-0e4deeb3a8bb.json": { - "checksum": "sha256:692825745edf96b9104d22a085ba0e369bbae48976f4e87b758365250c48aae4", - "topLevelKeys": [ - "deployment_plan_id", - "deployment_preview_id", - "observation_fingerprint", - "operations", - "platform", - "preview_version", - "runtime_target", - "source_identifier" - ] - }, - ".semapact/history/deployment_records/2f2c8752-0abf-5d19-b097-af057b4a0fe1.json": { - "checksum": "sha256:d88010da5c7fb72bf44fd9861a709ac13dd0339f64e2af8061057ddfc50c5b5f", - "topLevelKeys": [ - "actor_reference", - "completed_at", - "deployment_authorization_id", - "deployment_plan_id", - "deployment_preview_id", - "deployment_record_id", - "external_reference", - "platform", - "release_record_id", - "runtime_target", - "source_reference", - "started_at", - "status" - ] - }, - ".semapact/history/deployment_records/7fe4b74d-604c-58ec-94ba-f469651ea25a.json": { - "checksum": "sha256:c2c856bcf5a0301525b91291effcee95fbfb7a65a12e71fe0cf786df94d0e873", - "topLevelKeys": [ - "actor_reference", - "completed_at", - "deployment_authorization_id", - "deployment_plan_id", - "deployment_preview_id", - "deployment_record_id", - "external_reference", - "platform", - "release_record_id", - "runtime_target", - "source_reference", - "started_at", - "status" - ] - }, - ".semapact/history/release_plans/45a429c1-9ff4-54da-897a-b02c7ee6027f.json": { - "checksum": "sha256:e1f6e06acd06a1b0ff719ddce44430f293b03d65e5586607f13907d6b617b237", - "topLevelKeys": [ - "change_set_id", - "contract_id", - "decision_id", - "preconditions", - "release_plan_id", - "release_revision_ref", - "required_version_bump" - ] - }, - ".semapact/history/release_records/de7c4ed0-d54a-5a7a-b82e-c626eab39d23.json": { - "checksum": "sha256:961815c36c9f21e9ca1a277bb256bdc6571bcd94187a2712f333fa2bf6834b9d", - "topLevelKeys": [ - "actual_version_bump", - "applied_release_id", - "authority_reference", - "authorization_id", - "change_set_id", - "contract_id", - "contract_version", - "decision_id", - "release_plan_id", - "release_record_id", - "released_revision_id", - "required_version_bump", - "review_evidence_action", - "review_evidence_reference", - "version_authority", - "version_resolution_id" - ] - }, - ".semapact/history/runtime_observations/38695939-4fb5-583c-acfa-1a21397d89c8.json": { - "checksum": "sha256:dcad511a463004815942e9404e2d6ad5edd7efeb95afa14926f2b08b2c0bc389", - "topLevelKeys": [ - "observation", - "observation_record_id" - ] - }, - ".semapact/history/runtime_observations/e259c151-972f-532a-99d1-a14dcd78b166.json": { - "checksum": "sha256:d2af366ab491fdd0bfc123940f17f90257a456264343d017218c5791d2e1a6ad", - "topLevelKeys": [ - "observation", - "observation_record_id" - ] - }, - ".semapact/history/runtime_reconciliations/1e5b9772-02f3-5461-9a7b-a8cf73f6a609.json": { - "checksum": "sha256:fd71cdab46b7a848934f5a7cb7e21cc9e254cf1b9929987d49f2f684b76a54fc", - "topLevelKeys": [ - "deployment_record_id", - "observation_record_id", - "release_record_id", - "result", - "runtime_reconciliation_record_id", - "status" - ] - }, - ".semapact/history/runtime_reconciliations/7d8fcefb-3470-590b-ba48-1f1f9910be85.json": { - "checksum": "sha256:35c959957502dbe4a34f161582a9fc720b6010977b6a80bdcb81de7beba9f629", - "topLevelKeys": [ - "deployment_record_id", - "observation_record_id", - "release_record_id", - "result", - "runtime_reconciliation_record_id", - "status" - ] - } - }, - "fixtureVersion": "history-v1" -} From d8975b4f62fec48c9c4eefeb7e4eaacf76e88720 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:59:19 +1000 Subject: [PATCH 387/616] refactor(cli): remove obsolete release compatibility commands --- semapact/interfaces/commands/release_cmd.py | 69 --------------------- 1 file changed, 69 deletions(-) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index f6f97939..ae780066 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -181,47 +181,6 @@ def run_release_plan(args: argparse.Namespace) -> dict[str, Any]: } -def run_release_prepare(args: argparse.Namespace) -> dict[str, Any]: - """Compatibility release-tag helper retained for existing Git workflows.""" - from dataclasses import asdict - - from semapact.core.loader import ContractLoader - from semapact.core.release import apply_release_candidate - from semapact.governance import GovernanceOperation, enforce_governance_gate - from semapact.utils.schema_utils import contract_to_dict - from semapact.utils.yaml_utils import dump_yaml - - loader = ContractLoader(runtime_context=args.runtime_context) - base_contract = loader.load(args.base) - candidate_contract = loader.load(args.candidate) - - decision = GovernanceService().evaluate( - base_contract, - candidate_contract, - effective_date=args.effective_date, - ) - enforce_governance_gate(decision, GovernanceOperation.PROPOSE) - - result = apply_release_candidate( - base_contract, - candidate_contract, - args.release_tag, - required_bump=decision.required_version_bump, - ) - output_path = dump_yaml(contract_to_dict(result.contract), args.output) - return { - "contractId": str(result.contract.id or ""), - "currentVersion": result.current_version, - "targetVersion": result.target_version, - "requiredBump": result.required_bump, - "actualBump": result.actual_bump, - "releaseTag": result.release_tag, - "reasons": [r.message for r in decision.reasons], - "breakingChanges": [asdict(change) for change in decision.policy.breaking_changes], - "output": str(output_path), - "governanceDecision": decision.model_dump(mode="json"), - } - def run_release_classify_repo(args: argparse.Namespace) -> dict[str, Any]: from semapact.devops.release_workflow import ( @@ -266,34 +225,6 @@ def run_release_build_manifest(args: argparse.Namespace) -> dict[str, Any]: return payload -def run_release_create_pr(args: argparse.Namespace) -> dict[str, Any]: - """Compatibility Git publication workflow retained until a publisher is selected.""" - from semapact.core.loader import ContractLoader - from semapact.devops.release_workflow import create_release_pull_request - - loader = ContractLoader(runtime_context=args.runtime_context) - base_contract = loader.load(args.base) - candidate_contract = loader.load(args.candidate) - change_context = GovernanceService.create_context(args.effective_date) - - config = _build_git_config(args) - payload = create_release_pull_request( - config=config, - repo_path=_get_repo_path(args), - contract_repo_path=args.contract_path, - base_contract=base_contract, - candidate_contract=candidate_contract, - release_tag=args.release_tag, - source_branch=args.source_branch, - target_branch=args.target_branch, - context=change_context, - title=args.title, - description=args.description, - commit_message=args.commit_message, - push=args.push, - ) - return payload - def run_release_create_prs(args: argparse.Namespace) -> dict[str, Any]: from semapact.devops.release_workflow import ( From c03dde9de5730b0ac148dff79608c6e40fe868cb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:59:24 +1000 Subject: [PATCH 388/616] refactor(cli): expose canonical release commands only --- semapact/interfaces/cli.py | 50 +------------------------------------- 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index 637c4166..7aa1ab7f 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -381,45 +381,7 @@ def _build_parser() -> argparse.ArgumentParser: ) _add_effective_date_argument(release_build_manifest_parser) - release_prepare_parser = release_subparsers.add_parser( - "prepare", - help="Compatibility helper: prepare a candidate using an explicit release tag", - ) - release_prepare_parser.add_argument("--base", required=True) - release_prepare_parser.add_argument("--candidate", required=True) - release_prepare_parser.add_argument("--release-tag", required=True) - release_prepare_parser.add_argument("--output", required=True) - release_prepare_parser.add_argument("--runtime-context", default="auto") - _add_effective_date_argument(release_prepare_parser) - - release_pr_parser = release_subparsers.add_parser( - "create-pr", - help="Compatibility Git workflow: prepare a candidate and open a release PR", - ) - release_pr_parser.add_argument("--base", required=True) - release_pr_parser.add_argument("--candidate", required=True) - release_pr_parser.add_argument("--release-tag", required=True) - release_pr_parser.add_argument("--repo-path", help="Local repository path") - release_pr_parser.add_argument("--contract-path", required=True) - release_pr_parser.add_argument("--source-branch", required=True) - release_pr_parser.add_argument("--target-branch", required=True) - release_pr_parser.add_argument( - "--git-provider", - choices=["azure", "github"], - ) - release_pr_parser.add_argument("--organization") - release_pr_parser.add_argument("--github-owner") - release_pr_parser.add_argument("--github-repo") - release_pr_parser.add_argument("--github-token") - release_pr_parser.add_argument("--project") - release_pr_parser.add_argument("--repository-id") - release_pr_parser.add_argument("--pat-token") - release_pr_parser.add_argument("--title") - release_pr_parser.add_argument("--description") - release_pr_parser.add_argument("--commit-message") - release_pr_parser.add_argument("--push", action="store_true") - release_pr_parser.add_argument("--runtime-context", default="auto") - _add_effective_date_argument(release_pr_parser) + release_prs_parser = release_subparsers.add_parser( "create-prs", @@ -731,11 +693,9 @@ def main() -> int: run_release_build_manifest, run_release_classify, run_release_classify_repo, - run_release_create_pr, run_release_create_prs, run_release_finalize, run_release_plan, - run_release_prepare, ) if args.release_command == "assess": payload = run_release_assess(args) @@ -765,14 +725,6 @@ def main() -> int: payload = run_release_build_manifest(args) print(json.dumps(payload, indent=2, sort_keys=True)) return 0 - if args.release_command == "prepare": - payload = run_release_prepare(args) - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - if args.release_command == "create-pr": - payload = run_release_create_pr(args) - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 if args.release_command == "create-prs": payload = run_release_create_prs(args) print(json.dumps(payload, indent=2, sort_keys=True)) From 8964df09e73ab736e8411bdd13f542c4370ee536 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:59:43 +1000 Subject: [PATCH 389/616] docs(contractops): remove obsolete compatibility lifecycle --- docs/contractops_phases.md | 37 ++++++------------------------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/docs/contractops_phases.md b/docs/contractops_phases.md index 46244aef..3c55a509 100644 --- a/docs/contractops_phases.md +++ b/docs/contractops_phases.md @@ -148,9 +148,9 @@ operation scopeReference? ``` -`GovernanceDecision(REVIEW)` remains `REVIEW` after approval. Matching explicit review evidence produces an allowed `ContractOpsAuthorization`; it does not rewrite governance history. +`GovernanceDecision(REVIEW)` remains `REVIEW` after approval. Formal-release approval is represented by an immutable `ApprovalRecord` bound to the exact `ReleaseSnapshot` and `ReleaseBundle` digest. `ReleaseFinalizer` projects that evidence into the PUBLISH authorization check without rewriting governance history. -PUBLISH and DEPLOY are the external side-effect scopes that matter to the new workflow. The historical APPLY authorization remains supported for compatibility while callers migrate to pure `ReleaseSnapshot` materialization. Runtime DEPLOY remains scoped to the exact `DeploymentPlan` before mutation. +Runtime deployment is a separate boundary. SemaPact does not create a deployment-authorization artifact; the surrounding protected CI/CD environment controls whether runtime mutation may be invoked, while SemaPact validates exact source/plan binding and runtime freshness. ## RELEASE SNAPSHOT @@ -167,31 +167,13 @@ Release snapshot construction is pure. It materializes the exact selected ODCS s This pure snapshot is used only for formal `--release` bundles. Candidate deployment freezes the candidate directly in a non-release `DeploymentSourceSnapshot` and does not calculate a new semantic version. -## APPLY compatibility - -`apply_contract_release(...)` remains available for existing callers. It validates the historical `ContractOpsAuthorization(operation=APPLY)`, builds the same pure `ReleaseSnapshot`, and wraps that snapshot into the legacy `AppliedContractRelease` artifact. - -New CI deployment workflows must not require APPLY authorization merely to plan or preview a deployment. The compatibility wrapper exists to preserve current publication/history integrations while the authorization surface is simplified. - ## PUBLISH -PUBLISH publishes an applied contract release or release artifact. It is distinct from runtime deployment. - -It requires an allowed `ContractOpsAuthorization(operation=PUBLISH)` matching the exact applied release context before the publisher adapter is invoked. An APPLY authorization cannot authorize PUBLISH. - -ContractOps defines only a narrow publisher port: +PUBLISH is the formal-release authorization boundary. For a REVIEW decision, the approval must bind the exact `ReleaseSnapshot` and `ReleaseBundle` digest. For ALLOW, no explicit approval record is required. -```text -AppliedContractRelease - ↓ -ContractReleasePublisher.publish(...) - ↓ -opaque publication reference - ↓ -PublicationResult -``` +After that check, `ReleaseFinalizer` constructs the immutable, target-neutral `ContractRelease`. The CLI persists that release fact to the Git governance ledger and may also emit the `ContractRelease` JSON artifact for CI/CD handoff. -Git, storage, and other release-artifact publication behavior belongs in adapters rather than the ContractOps domain. +PUBLISH does not authorize runtime mutation. A `ContractRelease` records what was formally released; deployment remains a separate protected execution boundary. ## DEPLOY @@ -218,12 +200,5 @@ ContractOps distinguishes invalid release context from denied publication author - a valid formal release whose required PUBLISH approval is denied/missing → `ContractOpsAuthorizationError`; - runtime deployment fails closed on invalid source/plan provenance, stale runtime evidence, or unsupported provider transitions. -Unexpected publisher/runtime failures are not converted into governance decisions; they propagate as execution failures. - -## Compatibility helpers - -`semapact release prepare` and `semapact release create-pr` remain compatibility workflows for existing Git-based release processes. They are not the canonical ContractOps PLAN/APPLY/PUBLISH path and should not be treated as equivalent to `release plan` plus explicit authorization. - -`semapact.core.release.prepare_release_candidate()` likewise remains a backward-compatible helper and is not the canonical ContractOps APPLY path because it may classify changes itself. +Unexpected persistence/runtime failures are not converted into governance decisions; they propagate as execution failures. -New ContractOps flows consume the existing authoritative `GovernanceDecision`, `ChangeSet`, `ReleasePlan`, and `VersionResolution` instead of recomputing them. From 36a9ab760ef3a69bc67e581d2c22818b83a5a381 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:59:48 +1000 Subject: [PATCH 390/616] docs(deployment): document canonical-only artifact model --- docs/deployment_plans.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 0bd55df0..97da21e0 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -55,7 +55,7 @@ The orchestration above is provider-neutral. Platform packages configure or impl A `DeploymentPlan` is a deterministic, provider-neutral statement of the runtime state that one exact deployment source intends to converge toward. -Canonical plans are v5 and bind only the immutable `DeploymentSourceSnapshot` plus the target-specific convergence actions. Release provenance and candidate-versus-release mode live only in the source snapshot; they are not duplicated in `DeploymentPlan`. Legacy `AppliedContractRelease` / `ReleaseSnapshot` inputs and older plan payloads are verified and upgraded at the explicit compatibility boundary. +Canonical plans are v5 and bind only the immutable `DeploymentSourceSnapshot` plus the target-specific convergence actions. Release provenance and candidate-versus-release mode live only in the source snapshot; they are not duplicated in `DeploymentPlan`. Older deployment-plan shapes are not part of the supported model. The initial action vocabulary deliberately contains only: @@ -385,7 +385,7 @@ The CLI URI remains available for one-off/custom pipeline overrides, but ordinar Supported operational history backends in this slice are SQLite and Delta. The Delta backend is lazy and requires the `delta` optional extra. Operational event payloads carry an explicit schema version so the telemetry contract can evolve independently of governance-history models. -Legacy Git-backed deployment/runtime history artifacts remain readable for compatibility, but the canonical bundle workflow does not create new operational records in Git. +The Git ledger stores low-frequency governance facts only. Deployment/runtime telemetry is written only when an operational SQLite or Delta backend is configured. Operational events record concrete deployment occurrence facts including success/failure, exact bundle/plan/source provenance, target, timestamps, and reconciliation status when available. From b08cebc67af0279a9d40b8d025ba874b11a55b63 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 13:59:52 +1000 Subject: [PATCH 391/616] docs(examples): remove obsolete legacy wording --- examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 8bfc48ce..c99ef449 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,7 +23,7 @@ This folder contains reference assets for wiring SemaPact into CI/CD. - `github/semapact-enrich.yml` - on-demand LLM contract enrichment trigger - `github/semapact-release.yml` - - legacy release-promotion example + - release-promotion example ## Azure DevOps Examples From 3e44c62b29c46f943fc06bc6c27831aed70bdb56 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:00:03 +1000 Subject: [PATCH 392/616] docs(config): remove obsolete legacy wording --- semapact/core/config_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/core/config_schema.py b/semapact/core/config_schema.py index 6b5e0b31..d0252535 100644 --- a/semapact/core/config_schema.py +++ b/semapact/core/config_schema.py @@ -58,7 +58,7 @@ def as_uri(self) -> str: class HistoryConfig(BaseModel): - """Typed history configuration while unrelated legacy config remains extensible.""" + """Typed history configuration while unrelated configuration remains extensible.""" model_config = ConfigDict(frozen=True, extra="forbid") From e26c1b2bc3c6dcba0dfb33945a2f5b95d9337404 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:00:09 +1000 Subject: [PATCH 393/616] docs(config): refresh canonical schema description --- schemas/semapact-config.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemas/semapact-config.schema.json b/schemas/semapact-config.schema.json index 8fcd92ea..0a939a73 100644 --- a/schemas/semapact-config.schema.json +++ b/schemas/semapact-config.schema.json @@ -24,7 +24,7 @@ }, "HistoryConfig": { "additionalProperties": false, - "description": "Typed history configuration while unrelated legacy config remains extensible.", + "description": "Typed history configuration while unrelated configuration remains extensible.", "properties": { "operational": { "anyOf": [ From 7e7103e9a7b7b826cc382ea8c5bf7455c884a274 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:00:45 +1000 Subject: [PATCH 394/616] test(cli): describe canonical deployment surface --- tests/interfaces/test_deployment_cmd.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index ea1ac6a4..0d0918bc 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -86,7 +86,7 @@ def test_deployment_parser_exposes_candidate_and_finalized_release_modes() -> No @pytest.mark.parametrize( - "legacy_args", + "unsupported_args", [ ["deployment", "plan"], ["deployment", "preview"], @@ -96,11 +96,11 @@ def test_deployment_parser_exposes_candidate_and_finalized_release_modes() -> No ["deployment", "assess", "--release"], ], ) -def test_legacy_deployment_cli_surfaces_are_not_public(legacy_args) -> None: +def test_only_bundle_deployment_cli_surface_is_public(unsupported_args) -> None: parser = cli._build_parser() with pytest.raises(SystemExit) as exc: - parser.parse_args(legacy_args) + parser.parse_args(unsupported_args) assert exc.value.code == 2 From b5aba51d9eee3d571675361f23d693c7eaa3d056 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:00:52 +1000 Subject: [PATCH 395/616] test(cli): assert canonical release commands only --- tests/interfaces/test_release_plan_cmd.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/interfaces/test_release_plan_cmd.py b/tests/interfaces/test_release_plan_cmd.py index 1e794c43..cbaeea91 100644 --- a/tests/interfaces/test_release_plan_cmd.py +++ b/tests/interfaces/test_release_plan_cmd.py @@ -92,15 +92,17 @@ def test_main_routes_release_plan_to_release_command_adapter( assert json.loads(capsys.readouterr().out) == expected -def test_legacy_release_helpers_are_labeled_as_compatibility_paths() -> None: - help_text = cli._build_parser().format_help() - release_parser = cli._build_parser()._subparsers._group_actions[0].choices["release"] - release_help = release_parser.format_help() +@pytest.mark.parametrize( + "unsupported_command", + ["prepare", "create-pr"], +) +def test_removed_release_commands_are_not_registered(unsupported_command: str) -> None: + parser = cli._build_parser() - assert "release" in help_text - assert "Compatibility helper" in release_help - assert "Compatibility Git workflow" in release_help + with pytest.raises(SystemExit) as exc: + parser.parse_args(["release", unsupported_command]) + assert exc.value.code == 2 def test_release_parser_exposes_assess_approve_finalize() -> None: From 78d27da5e730f4003469e6d601d61df30419948e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:01:52 +1000 Subject: [PATCH 396/616] refactor(release): isolate repository classification --- .../services/repository_classification.py | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 semapact/application/services/repository_classification.py diff --git a/semapact/application/services/repository_classification.py b/semapact/application/services/repository_classification.py new file mode 100644 index 00000000..6ec02d8a --- /dev/null +++ b/semapact/application/services/repository_classification.py @@ -0,0 +1,141 @@ +"""Repository-level contract change classification for central contract repositories.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from semapact.change_context import ChangeContext +from semapact.governance import DecisionResult, GovernanceDecision, evaluate_governance_decision +from semapact.utils.schema_utils import contract_to_model +from semapact.utils.yaml_utils import list_yaml_documents, load_yaml +from semapact.versioning import RequiredBump, suggest_release_version + + +@dataclass(slots=True) +class RepositoryContractChange: + """Per-contract change status within a repository comparison.""" + + contract_repo_path: str + status: str + contract_id: str | None = None + current_version: str | None = None + candidate_version: str | None = None + required_bump: RequiredBump | None = "none" + suggested_release_version: str | None = None + reasons: list[str] | None = None + governance_decision: GovernanceDecision | None = None + + +def classify_contracts_in_repo( + *, + base_root: str | Path, + candidate_root: str | Path, + context: ChangeContext, +) -> list[RepositoryContractChange]: + """Compare two contract roots using the canonical governance evaluator.""" + base_root_path = Path(base_root).expanduser().resolve() + candidate_root_path = Path(candidate_root).expanduser().resolve() + + base_index = _relative_contract_index(base_root_path) + candidate_index = _relative_contract_index(candidate_root_path) + + results: list[RepositoryContractChange] = [] + for relative_path in sorted(set(base_index) | set(candidate_index)): + base_path = base_index.get(relative_path) + candidate_path = candidate_index.get(relative_path) + + if base_path is None: + assert candidate_path is not None + candidate = contract_to_model(load_yaml(candidate_path)) + results.append( + RepositoryContractChange( + contract_repo_path=relative_path, + status="added", + contract_id=str(candidate.id or ""), + candidate_version=str(candidate.version or ""), + required_bump=None, + reasons=["New governed contract"], + ) + ) + continue + + if candidate_path is None: + base = contract_to_model(load_yaml(base_path)) + results.append( + RepositoryContractChange( + contract_repo_path=relative_path, + status="removed", + contract_id=str(base.id or ""), + current_version=str(base.version or ""), + required_bump=None, + reasons=["Governed contract missing from candidate root"], + ) + ) + continue + + base = contract_to_model(load_yaml(base_path)) + candidate = contract_to_model(load_yaml(candidate_path)) + decision = evaluate_governance_decision(base, candidate, context=context) + status = ( + "blocked" + if decision.decision is DecisionResult.BLOCK + else ("changed" if decision.evidence.has_changes else "unchanged") + ) + required_bump = decision.required_version_bump + results.append( + RepositoryContractChange( + contract_repo_path=relative_path, + status=status, + contract_id=str(base.id or ""), + current_version=str(base.version or ""), + candidate_version=str(candidate.version or ""), + required_bump=required_bump, + suggested_release_version=( + suggest_release_version(str(base.version or ""), required_bump) + if required_bump != "none" + else None + ), + reasons=[reason.message for reason in decision.reasons] + or ["No contract changes detected"], + governance_decision=decision, + ) + ) + return results + + +def repository_change_to_dict(change: RepositoryContractChange) -> dict[str, Any]: + """Serialize repository classification for CLI and CI matrix consumers.""" + decision = ( + change.governance_decision.model_dump(mode="json") + if change.governance_decision is not None + else None + ) + return { + "contract_repo_path": change.contract_repo_path, + "contractRepoPath": change.contract_repo_path, + "status": change.status, + "contract_id": change.contract_id, + "contractId": change.contract_id, + "current_version": change.current_version, + "currentVersion": change.current_version, + "candidate_version": change.candidate_version, + "candidateVersion": change.candidate_version, + "required_bump": change.required_bump, + "requiredBump": change.required_bump, + "suggested_release_version": change.suggested_release_version, + "suggestedReleaseVersion": change.suggested_release_version, + "reasons": change.reasons, + "governance_decision": decision, + "governanceDecision": decision, + } + + +def _relative_contract_index(root: Path) -> dict[str, Path]: + if not root.exists(): + return {} + return { + str(path.relative_to(root)): path + for path in (Path(item) for item in list_yaml_documents(root)) + } From afaefd25121061f263e262248f616ad73a2382b7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:02:00 +1000 Subject: [PATCH 397/616] refactor(release): remove legacy batch release commands --- semapact/interfaces/commands/release_cmd.py | 51 +-------------------- 1 file changed, 1 insertion(+), 50 deletions(-) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index ae780066..54c841df 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -183,7 +183,7 @@ def run_release_plan(args: argparse.Namespace) -> dict[str, Any]: def run_release_classify_repo(args: argparse.Namespace) -> dict[str, Any]: - from semapact.devops.release_workflow import ( + from semapact.application.services.repository_classification import ( classify_contracts_in_repo, repository_change_to_dict, ) @@ -196,52 +196,3 @@ def run_release_classify_repo(args: argparse.Namespace) -> dict[str, Any]: ) return {"contracts": [repository_change_to_dict(item) for item in results]} - -def run_release_build_manifest(args: argparse.Namespace) -> dict[str, Any]: - from semapact.devops.release_workflow import ( - batch_manifest_build_to_dict, - batch_task_to_dict, - build_batch_release_manifest, - ) - - change_context = GovernanceService.create_context(args.effective_date) - build = build_batch_release_manifest( - base_root=args.base_root, - candidate_root=args.candidate_root, - context=change_context, - target_branch=args.target_branch, - source_branch_prefix=args.source_branch_prefix, - ) - output_path = Path(args.output).expanduser().resolve() - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text( - json.dumps( - [batch_task_to_dict(item) for item in build.tasks], indent=2, sort_keys=True - ), - encoding="utf-8", - ) - payload = batch_manifest_build_to_dict(build) - payload["output"] = str(output_path) - return payload - - - -def run_release_create_prs(args: argparse.Namespace) -> dict[str, Any]: - from semapact.devops.release_workflow import ( - batch_task_to_dict, - create_release_pull_requests_from_manifest, - load_batch_release_tasks, - ) - - config = _build_git_config(args) - tasks = load_batch_release_tasks(args.manifest) - payload = create_release_pull_requests_from_manifest( - config=config, - repo_path=_get_repo_path(args), - tasks=tasks, - push=args.push, - ) - return { - "tasks": [batch_task_to_dict(item) for item in tasks], - "results": payload, - } From 88d5d95529d99051df21e2bf76ce933e9f240645 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:02:19 +1000 Subject: [PATCH 398/616] refactor(cli): remove legacy batch release surface --- semapact/interfaces/cli.py | 44 -------------------------------------- 1 file changed, 44 deletions(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index 7aa1ab7f..013e409c 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -368,40 +368,6 @@ def _build_parser() -> argparse.ArgumentParser: release_classify_repo_parser.add_argument("--candidate-root", required=True) _add_effective_date_argument(release_classify_repo_parser) - release_build_manifest_parser = release_subparsers.add_parser( - "build-manifest", - help="Build an editable per-contract release manifest from two contract roots", - ) - release_build_manifest_parser.add_argument("--base-root", required=True) - release_build_manifest_parser.add_argument("--candidate-root", required=True) - release_build_manifest_parser.add_argument("--output", required=True) - release_build_manifest_parser.add_argument("--target-branch", default="release") - release_build_manifest_parser.add_argument( - "--source-branch-prefix", default="release/" - ) - _add_effective_date_argument(release_build_manifest_parser) - - - - release_prs_parser = release_subparsers.add_parser( - "create-prs", - help="Run explicit per-contract release PR automation from a batch manifest", - ) - release_prs_parser.add_argument("--manifest", required=True) - release_prs_parser.add_argument("--repo-path", help="Local repository path") - release_prs_parser.add_argument( - "--git-provider", - choices=["azure", "github"], - ) - release_prs_parser.add_argument("--organization") - release_prs_parser.add_argument("--github-owner") - release_prs_parser.add_argument("--github-repo") - release_prs_parser.add_argument("--github-token") - release_prs_parser.add_argument("--project") - release_prs_parser.add_argument("--repository-id") - release_prs_parser.add_argument("--pat-token") - release_prs_parser.add_argument("--push", action="store_true") - doctor_parser = subparsers.add_parser( "doctor", @@ -690,10 +656,8 @@ def main() -> int: from semapact.interfaces.commands.release_cmd import ( run_release_approve, run_release_assess, - run_release_build_manifest, run_release_classify, run_release_classify_repo, - run_release_create_prs, run_release_finalize, run_release_plan, ) @@ -721,14 +685,6 @@ def main() -> int: payload = run_release_classify_repo(args) print(json.dumps(payload, indent=2, sort_keys=True)) return 0 - if args.release_command == "build-manifest": - payload = run_release_build_manifest(args) - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - if args.release_command == "create-prs": - payload = run_release_create_prs(args) - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 from semapact.interfaces.outcomes import CliExitCode parser.error(f"Unknown command: {args.command}") From 3d4f9909586b3598ab3d14f783b08ab2fa79f5a5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:02:34 +1000 Subject: [PATCH 399/616] refactor: remove obsolete compatibility workflow --- semapact/core/release.py | 166 --------------------------------------- 1 file changed, 166 deletions(-) delete mode 100644 semapact/core/release.py diff --git a/semapact/core/release.py b/semapact/core/release.py deleted file mode 100644 index 17212933..00000000 --- a/semapact/core/release.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -import logging -import re -from dataclasses import dataclass, field -from typing import Any - -from open_data_contract_standard.model import OpenDataContractStandard - -from semapact.lifecycle.change_classification import ( - ContractChangeAssessment, - classify_contract_change, -) -from semapact.lifecycle.policy import BreakingChange -from semapact.utils.schema_utils import contract_to_model -from semapact.versioning import ( - ActualVersionBump, - RequiredBump, - classify_version_bump, - increment_version, - normalize_semver, - suggest_release_version, - version_bump_satisfies, -) - - -LOGGER = logging.getLogger(__name__) - -SEMVER_TAG_RE = re.compile(r"(?:^|[/-])v?(?P\d+\.\d+\.\d+)$") - - -@dataclass(slots=True) -class PromotionResult: - """Prepared release candidate for a single governed contract.""" - - contract: OpenDataContractStandard - required_bump: RequiredBump - current_version: str - target_version: str - actual_bump: ActualVersionBump - release_tag: str - reasons: list[str] = field(default_factory=list) - breaking_changes: list[BreakingChange] = field(default_factory=list) - - -def apply_release_candidate( - base_contract: OpenDataContractStandard, - candidate_contract: OpenDataContractStandard, - release_tag: str, - *, - required_bump: RequiredBump, -) -> PromotionResult: - """Apply a legacy release candidate using a pre-calculated required bump. - - This compatibility workflow preserves its historical semantics. Canonical M2 - ContractOps release execution lives outside this module. - """ - if not isinstance(base_contract, OpenDataContractStandard): - raise TypeError( - f"base_contract must be OpenDataContractStandard, got {type(base_contract).__name__}" - ) - if not isinstance(candidate_contract, OpenDataContractStandard): - raise TypeError( - "candidate_contract must be OpenDataContractStandard, " - f"got {type(candidate_contract).__name__}" - ) - - base_model = base_contract - candidate_model = candidate_contract.model_copy(deep=True) - - candidate_model.id = base_model.id - candidate_model.version = base_model.version - - LOGGER.info( - "Applying release candidate for contract %s with tag %s (required bump: %s)", - base_model.id, - release_tag, - required_bump, - ) - - if required_bump == "none": - from semapact.exceptions import ReleaseValidationError - - raise ReleaseValidationError("Contract changes do not require a release version bump") - - target_version = parse_release_tag_version(release_tag) - actual_bump = classify_version_bump(str(base_model.version or ""), target_version) - if not version_bump_satisfies(actual_bump, required_bump): - from semapact.exceptions import ReleaseValidationError - - raise ReleaseValidationError( - f"Release tag '{release_tag}' applies a {actual_bump} bump, but contract requires at least a " - f"{required_bump} bump" - ) - - promoted = candidate_model.model_copy(deep=True) - promoted.version = target_version - return PromotionResult( - contract=promoted, - required_bump=required_bump, - current_version=str(base_model.version or ""), - target_version=target_version, - actual_bump=actual_bump, - release_tag=release_tag, - reasons=[], - breaking_changes=[], - ) - - -def prepare_release_candidate( - base_contract: OpenDataContractStandard | dict[str, Any], - candidate_contract: OpenDataContractStandard | dict[str, Any], - release_tag: str, -) -> PromotionResult: - """Prepare a promoted contract candidate through the legacy compatibility path.""" - base_model = contract_to_model(base_contract) - candidate_model = contract_to_model(candidate_contract) - - assessment = classify_contract_change(base_model, candidate_model) - if not assessment.has_changes: - LOGGER.error("Preparation failed: contract %s has no changes", base_model.id) - raise ValueError("Cannot promote a contract with no changes") - - result = apply_release_candidate( - base_model, - candidate_model, - release_tag, - required_bump=assessment.required_bump, - ) - return PromotionResult( - contract=result.contract, - required_bump=result.required_bump, - current_version=result.current_version, - target_version=result.target_version, - actual_bump=result.actual_bump, - release_tag=result.release_tag, - reasons=assessment.reasons, - breaking_changes=assessment.breaking_changes, - ) - - -def parse_release_tag_version(release_tag: str) -> str: - """Extract semantic version from an explicit legacy release tag.""" - text = str(release_tag or "").strip() - match = SEMVER_TAG_RE.search(text) - if not match: - raise ValueError( - f"Release tag '{release_tag}' must end with a semantic version like v1.2.3" - ) - return match.group("version") - - -__all__ = [ - "ActualVersionBump", - "ContractChangeAssessment", - "PromotionResult", - "RequiredBump", - "apply_release_candidate", - "classify_contract_change", - "classify_version_bump", - "increment_version", - "normalize_semver", - "parse_release_tag_version", - "prepare_release_candidate", - "suggest_release_version", -] From 166b46a1cf56bd18ec4895dbd85e36242a27e703 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:02:37 +1000 Subject: [PATCH 400/616] refactor: remove obsolete compatibility workflow --- semapact/devops/release_workflow.py | 465 ---------------------------- 1 file changed, 465 deletions(-) delete mode 100644 semapact/devops/release_workflow.py diff --git a/semapact/devops/release_workflow.py b/semapact/devops/release_workflow.py deleted file mode 100644 index 8c98de1d..00000000 --- a/semapact/devops/release_workflow.py +++ /dev/null @@ -1,465 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import Any - -from semapact.core.release import ( - PromotionResult, - RequiredBump, - apply_release_candidate, - suggest_release_version, -) -from semapact.devops.pr_creator import ( - GitProviderConfig, - PullRequestCreator, -) -from semapact.exceptions import GovernanceBlockedError -from semapact.governance import ( - ChangeContext, - DecisionResult, - GovernanceDecision, - GovernanceOperation, - enforce_governance_gate, - evaluate_governance_decision, -) -from open_data_contract_standard.model import OpenDataContractStandard -from semapact.utils.schema_utils import contract_to_model -from semapact.utils.yaml_utils import dump_yaml, list_yaml_documents, load_yaml - - -@dataclass(slots=True) -class ReleasePullRequestPlan: - """Prepared per-contract release PR payload.""" - - contract_id: str - current_version: str - target_version: str - required_bump: str - actual_bump: str - release_tag: str - contract_repo_path: str - source_branch: str - target_branch: str - commit_message: str - title: str - description: str - - -@dataclass(slots=True) -class RepositoryContractChange: - """Per-contract change status within a multi-contract repo comparison.""" - - contract_repo_path: str - status: str - contract_id: str | None = None - current_version: str | None = None - candidate_version: str | None = None - required_bump: RequiredBump | None = "none" - suggested_release_version: str | None = None - reasons: list[str] | None = None - governance_decision: GovernanceDecision | None = None - - -@dataclass(slots=True) -class BatchReleaseTask: - """Explicit per-contract release task for batch orchestration.""" - - base: str - candidate: str - contract_path: str - release_tag: str - source_branch: str - target_branch: str - effective_date: str - title: str | None = None - description: str | None = None - commit_message: str | None = None - - -@dataclass(slots=True) -class BatchReleaseManifestBuild: - """Generated batch manifest plus skipped contract summary.""" - - tasks: list[BatchReleaseTask] - skipped: list[RepositoryContractChange] - - -def build_release_pr_plan( - *, - promotion: PromotionResult, - contract_repo_path: str, - source_branch: str, - target_branch: str, - title: str | None = None, - description: str | None = None, - commit_message: str | None = None, -) -> ReleasePullRequestPlan: - """Build a per-contract release PR plan from a prepared promotion result.""" - contract_id = str(promotion.contract.id or "") - target_version = promotion.target_version - default_title = f"Release {contract_id} {target_version}" - default_commit = f"release({contract_id}): prepare {target_version}" - default_description = ( - f"Prepare release for contract `{contract_id}`.\n\n" - f"- current version: `{promotion.current_version}`\n" - f"- target version: `{promotion.target_version}`\n" - f"- required bump: `{promotion.required_bump}`\n" - f"- actual bump: `{promotion.actual_bump}`\n" - f"- release tag: `{promotion.release_tag}`\n" - ) - return ReleasePullRequestPlan( - contract_id=contract_id, - current_version=promotion.current_version, - target_version=target_version, - required_bump=promotion.required_bump, - actual_bump=promotion.actual_bump, - release_tag=promotion.release_tag, - contract_repo_path=contract_repo_path, - source_branch=source_branch, - target_branch=target_branch, - commit_message=commit_message or default_commit, - title=title or default_title, - description=description or default_description, - ) - - -def create_release_pull_request( - *, - config: GitProviderConfig, - repo_path: str, - contract_repo_path: str, - base_contract: OpenDataContractStandard, - candidate_contract: OpenDataContractStandard, - release_tag: str, - source_branch: str, - target_branch: str, - context: ChangeContext, - title: str | None = None, - description: str | None = None, - commit_message: str | None = None, - push: bool = False, -) -> dict[str, Any]: - """Prepare one promoted contract and open a release PR for it.""" - # 1. Authoritative decision evaluation and PROPOSE gate enforcement before file write or Git/PR actions - decision = evaluate_governance_decision( - base_contract, - candidate_contract, - context=context, - ) - enforce_governance_gate(decision, GovernanceOperation.PROPOSE) - - promotion = apply_release_candidate( - base_contract, - candidate_contract, - release_tag, - required_bump=decision.required_version_bump, - ) - repo_root = Path(repo_path).expanduser().resolve() - contract_path = repo_root / contract_repo_path - dump_yaml(promotion.contract, contract_path) - - plan = build_release_pr_plan( - promotion=promotion, - contract_repo_path=contract_repo_path, - source_branch=source_branch, - target_branch=target_branch, - title=title, - description=description, - commit_message=commit_message, - ) - - creator = PullRequestCreator(config=config) - pr_payload = creator.create_update_pr( - repo_path=str(repo_root), - source_branch=source_branch, - target_branch=target_branch, - commit_message=plan.commit_message, - title=plan.title, - description=plan.description, - paths=[contract_repo_path], - push=push, - ) - return { - "promotion": { - "contractId": plan.contract_id, - "currentVersion": plan.current_version, - "targetVersion": plan.target_version, - "requiredBump": plan.required_bump, - "actualBump": plan.actual_bump, - "releaseTag": plan.release_tag, - "contractPath": plan.contract_repo_path, - "sourceBranch": plan.source_branch, - "targetBranch": plan.target_branch, - }, - "pullRequest": pr_payload, - "governanceDecision": decision.model_dump(mode="json"), - } - - -def release_plan_to_dict(plan: ReleasePullRequestPlan) -> dict[str, Any]: - """Serialize a release PR plan for CLI/JSON output.""" - return asdict(plan) - - -def classify_contracts_in_repo( - *, - base_root: str | Path, - candidate_root: str | Path, - context: ChangeContext, -) -> list[RepositoryContractChange]: - """Compare two contract roots and classify changes per contract file based on GovernanceDecision.""" - base_root_path = Path(base_root).expanduser().resolve() - candidate_root_path = Path(candidate_root).expanduser().resolve() - - base_index = _relative_contract_index(base_root_path) - candidate_index = _relative_contract_index(candidate_root_path) - - results: list[RepositoryContractChange] = [] - for relative_path in sorted(set(base_index) | set(candidate_index)): - base_path = base_index.get(relative_path) - candidate_path = candidate_index.get(relative_path) - - if base_path is None: - assert candidate_path is not None - candidate_model = contract_to_model(load_yaml(candidate_path)) - results.append( - RepositoryContractChange( - contract_repo_path=relative_path, - status="added", - contract_id=str(candidate_model.id or ""), - current_version=None, - candidate_version=str(candidate_model.version or ""), - required_bump=None, - suggested_release_version=None, - reasons=[ - "New governed contract; initial release handled separately" - ], - governance_decision=None, - ) - ) - continue - - if candidate_path is None: - base_model = contract_to_model(load_yaml(base_path)) - results.append( - RepositoryContractChange( - contract_repo_path=relative_path, - status="removed", - contract_id=str(base_model.id or ""), - current_version=str(base_model.version or ""), - candidate_version=None, - required_bump=None, - suggested_release_version=None, - reasons=[ - "Governed contract missing from candidate root; manual review required" - ], - governance_decision=None, - ) - ) - continue - - base_model = contract_to_model(load_yaml(base_path)) - candidate_model = contract_to_model(load_yaml(candidate_path)) - - decision = evaluate_governance_decision( - base_model, - candidate_model, - context=context, - ) - reasons_list = [r.message for r in decision.reasons] or ["No contract changes detected"] - - status = ( - "blocked" - if decision.decision == DecisionResult.BLOCK - else ("changed" if decision.evidence.has_changes else "unchanged") - ) - - results.append( - RepositoryContractChange( - contract_repo_path=relative_path, - status=status, - contract_id=str(base_model.id or ""), - current_version=str(base_model.version or ""), - candidate_version=str(candidate_model.version or ""), - required_bump=decision.required_version_bump, - suggested_release_version=( - suggest_release_version( - str(base_model.version or ""), - decision.required_version_bump, - ) - if decision.required_version_bump != "none" - else None - ), - reasons=reasons_list, - governance_decision=decision, - ) - ) - - return results - - -def create_release_pull_requests_from_manifest( - *, - config: GitProviderConfig, - repo_path: str, - tasks: list[BatchReleaseTask], - push: bool = False, -) -> list[dict[str, Any]]: - """Run explicit per-contract release PR automation from a batch manifest.""" - results: list[dict[str, Any]] = [] - for task in tasks: - base_contract = contract_to_model(load_yaml(task.base)) - candidate_contract = contract_to_model(load_yaml(task.candidate)) - task_context = ChangeContext(effective_date=task.effective_date) - - results.append( - create_release_pull_request( - config=config, - repo_path=repo_path, - contract_repo_path=task.contract_path, - base_contract=base_contract, - candidate_contract=candidate_contract, - release_tag=task.release_tag, - source_branch=task.source_branch, - target_branch=task.target_branch, - context=task_context, - title=task.title, - description=task.description, - commit_message=task.commit_message, - push=push, - ) - ) - return results - - -def build_batch_release_manifest( - *, - base_root: str | Path, - candidate_root: str | Path, - context: ChangeContext, - target_branch: str = "release", - source_branch_prefix: str = "release/", -) -> BatchReleaseManifestBuild: - """Build an editable batch manifest from repo-level contract changes.""" - changes = classify_contracts_in_repo( - base_root=base_root, - candidate_root=candidate_root, - context=context, - ) - base_root_path = Path(base_root).expanduser().resolve() - candidate_root_path = Path(candidate_root).expanduser().resolve() - - tasks: list[BatchReleaseTask] = [] - skipped: list[RepositoryContractChange] = [] - for change in changes: - if change.status != "changed" or change.required_bump == "none": - skipped.append(change) - continue - - # Enforce PROPOSE gate using GovernanceBlockedError exception catching to handle blocked changes - if change.governance_decision is not None: - try: - enforce_governance_gate( - change.governance_decision, - GovernanceOperation.PROPOSE, - ) - except GovernanceBlockedError: - skipped.append(change) - continue - - contract_key = _contract_release_key(change) - next_version = change.suggested_release_version or suggest_release_version( - str(change.current_version or "0.0.0"), - change.required_bump or "none", - ) - release_tag = f"{contract_key}/v{next_version}" - source_branch = ( - f"{source_branch_prefix}{_branch_safe_name(contract_key)}-v{next_version}" - ) - - tasks.append( - BatchReleaseTask( - base=str(base_root_path / change.contract_repo_path), - candidate=str(candidate_root_path / change.contract_repo_path), - contract_path=change.contract_repo_path, - release_tag=release_tag, - source_branch=source_branch, - target_branch=target_branch, - effective_date=context.effective_date.isoformat(), - ) - ) - - return BatchReleaseManifestBuild(tasks=tasks, skipped=skipped) - - -def load_batch_release_tasks(path: str | Path) -> list[BatchReleaseTask]: - """Load a JSON batch manifest for per-contract release orchestration.""" - manifest_path = Path(path).expanduser().resolve() - payload = json.loads(manifest_path.read_text(encoding="utf-8")) - if not isinstance(payload, list): - raise ValueError("Batch release manifest must be a JSON array") - return [BatchReleaseTask(**item) for item in payload] - - -def repository_change_to_dict(change: RepositoryContractChange) -> dict[str, Any]: - """Serialize repo-level change result for CLI/JSON output.""" - gov_dec_dict = ( - change.governance_decision.model_dump(mode="json") - if change.governance_decision is not None - else None - ) - - return { - "contract_repo_path": change.contract_repo_path, - "contractRepoPath": change.contract_repo_path, - "status": change.status, - "contract_id": change.contract_id, - "contractId": change.contract_id, - "current_version": change.current_version, - "currentVersion": change.current_version, - "candidate_version": change.candidate_version, - "candidateVersion": change.candidate_version, - "required_bump": change.required_bump, - "requiredBump": change.required_bump, - "suggested_release_version": change.suggested_release_version, - "suggestedReleaseVersion": change.suggested_release_version, - "reasons": change.reasons, - "governance_decision": gov_dec_dict, - "governanceDecision": gov_dec_dict, - } - - -def batch_task_to_dict(task: BatchReleaseTask) -> dict[str, Any]: - """Serialize a batch release task for debugging/output.""" - return asdict(task) - - -def batch_manifest_build_to_dict(build: BatchReleaseManifestBuild) -> dict[str, Any]: - """Serialize manifest build result for CLI/JSON output.""" - return { - "tasks": [batch_task_to_dict(task) for task in build.tasks], - "skipped": [repository_change_to_dict(change) for change in build.skipped], - } - - -def _relative_contract_index(root: Path) -> dict[str, Path]: - if not root.exists(): - return {} - documents = [Path(path) for path in list_yaml_documents(root)] - return {str(path.relative_to(root)): path for path in documents} - - -def _contract_release_key(change: RepositoryContractChange) -> str: - contract_id = str(change.contract_id or "").strip() - if contract_id: - return contract_id - return Path(change.contract_repo_path).stem - - -def _branch_safe_name(value: str) -> str: - cleaned = "".join( - char if char.isalnum() or char in {"-", "_"} else "-" for char in value.strip() - ) - return cleaned.strip("-") or "contract" From 3d1663f9ac9cdd4fc3313820da06d8ddfa72cb3f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:02:39 +1000 Subject: [PATCH 401/616] refactor: remove obsolete compatibility workflow --- semapact/services/__init__.py | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 semapact/services/__init__.py diff --git a/semapact/services/__init__.py b/semapact/services/__init__.py deleted file mode 100644 index a9db5846..00000000 --- a/semapact/services/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Backward-compatible imports for the pre-application package layout. - -New code must import application services and application result models from -``semapact.application``. This package owns no business logic or data models. -""" - -from semapact.application.models import ( - GovernanceAnalysis, - GovernanceProposal, - ReleasePlanningResult, - RuntimeReconciliation, -) -from semapact.application.services.deployment import DeploymentService -from semapact.application.services.governance import GovernanceService -from semapact.application.services.reconciliation import ReconciliationService -from semapact.application.services.release_planning import ReleasePlanningService -from semapact.application.services.version_authority import VersionAuthorityService - -__all__ = [ - "DeploymentService", - "GovernanceAnalysis", - "GovernanceProposal", - "GovernanceService", - "ReconciliationService", - "ReleasePlanningResult", - "ReleasePlanningService", - "RuntimeReconciliation", - "VersionAuthorityService", -] From ccbc5e009d1ea653b670364fe576b69110008331 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:02:44 +1000 Subject: [PATCH 402/616] refactor: remove obsolete compatibility workflow --- semapact/services/deployment_service.py | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 semapact/services/deployment_service.py diff --git a/semapact/services/deployment_service.py b/semapact/services/deployment_service.py deleted file mode 100644 index 64806ad9..00000000 --- a/semapact/services/deployment_service.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility import for the former deployment service module.""" - -from semapact.application.services.deployment import DeploymentService - -__all__ = ["DeploymentService"] From 3da9b4a89bc2f5ed288fe611b3f4e5152c67f759 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:02:47 +1000 Subject: [PATCH 403/616] refactor: remove obsolete compatibility workflow --- semapact/services/governance_service.py | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 semapact/services/governance_service.py diff --git a/semapact/services/governance_service.py b/semapact/services/governance_service.py deleted file mode 100644 index 3b1ada83..00000000 --- a/semapact/services/governance_service.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Compatibility imports for the former governance service module.""" - -from semapact.application.models.governance import GovernanceAnalysis, GovernanceProposal -from semapact.application.services.governance import GovernanceService - -__all__ = ["GovernanceAnalysis", "GovernanceProposal", "GovernanceService"] From 311048102c929bbb4f450a5360f3a7d096186b7b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:02:49 +1000 Subject: [PATCH 404/616] refactor: remove obsolete compatibility workflow --- semapact/services/reconciliation_service.py | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 semapact/services/reconciliation_service.py diff --git a/semapact/services/reconciliation_service.py b/semapact/services/reconciliation_service.py deleted file mode 100644 index 1f52bc0a..00000000 --- a/semapact/services/reconciliation_service.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Compatibility imports for the former reconciliation service module.""" - -from semapact.application.models.reconciliation import RuntimeReconciliation -from semapact.application.services.reconciliation import ReconciliationService - -__all__ = ["ReconciliationService", "RuntimeReconciliation"] From 445e935a4dd2ef54701a0e273ac7637182f5b9de Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:02:52 +1000 Subject: [PATCH 405/616] refactor: remove obsolete compatibility workflow --- semapact/services/release_models.py | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 semapact/services/release_models.py diff --git a/semapact/services/release_models.py b/semapact/services/release_models.py deleted file mode 100644 index 39b1a9f0..00000000 --- a/semapact/services/release_models.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility import for the former release application-model module.""" - -from semapact.application.models.release import ReleasePlanningResult - -__all__ = ["ReleasePlanningResult"] From 1d0beb0caf382cd2d46eb4f1b96cef3307197fd7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:02:56 +1000 Subject: [PATCH 406/616] refactor: remove obsolete compatibility workflow --- semapact/services/release_planning_service.py | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 semapact/services/release_planning_service.py diff --git a/semapact/services/release_planning_service.py b/semapact/services/release_planning_service.py deleted file mode 100644 index e78cc0c7..00000000 --- a/semapact/services/release_planning_service.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility import for the former release-planning service module.""" - -from semapact.application.services.release_planning import ReleasePlanningService - -__all__ = ["ReleasePlanningService"] From 7bb216f71f005e4467bfb31c9bb77dff310fc28b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:02:58 +1000 Subject: [PATCH 407/616] refactor: remove obsolete compatibility workflow --- semapact/services/version_authority_service.py | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 semapact/services/version_authority_service.py diff --git a/semapact/services/version_authority_service.py b/semapact/services/version_authority_service.py deleted file mode 100644 index 825a2671..00000000 --- a/semapact/services/version_authority_service.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility import for the former version-authority service module.""" - -from semapact.application.services.version_authority import VersionAuthorityService - -__all__ = ["VersionAuthorityService"] From 9996931ed4ecc6910565a3d7061544e29a3c4398 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:03:02 +1000 Subject: [PATCH 408/616] refactor: remove obsolete compatibility workflow --- ...test_semapact_release_workflow_readable.py | 131 ------------------ 1 file changed, 131 deletions(-) delete mode 100644 tests/test_semapact_release_workflow_readable.py diff --git a/tests/test_semapact_release_workflow_readable.py b/tests/test_semapact_release_workflow_readable.py deleted file mode 100644 index a5e498f3..00000000 --- a/tests/test_semapact_release_workflow_readable.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - - -import pytest -from open_data_contract_standard.model import CustomProperty, SchemaProperty - -from semapact.core.release import ( - classify_contract_change, - classify_version_bump, - parse_release_tag_version, - prepare_release_candidate, - suggest_release_version, -) - - -def test_release_classification_returns_none_for_description_only_change( - sample_odcs_model, -): - base = sample_odcs_model.model_copy(deep=True) - candidate = sample_odcs_model.model_copy(deep=True) - assert candidate.description is not None - candidate.description.usage = "Updated descriptive text only" - - result = classify_contract_change(base, candidate) - - assert result.has_changes is True - assert result.required_bump == "none" - - -def test_release_classification_returns_minor_for_added_property(sample_odcs_model): - base = sample_odcs_model.model_copy(deep=True) - candidate = sample_odcs_model.model_copy(deep=True) - candidate.schema_[0].properties.append( # type: ignore[index,union-attr] - SchemaProperty( - name="new_optional_column", - logicalType="string", - physicalType="STRING", - required=False, - ) - ) - - result = classify_contract_change(base, candidate) - - assert result.required_bump == "minor" - assert any("minor version bump" in reason for reason in result.reasons) - - -def test_release_classification_treats_deprecation_as_minor(sample_odcs_model): - base = sample_odcs_model.model_copy(deep=True) - candidate = sample_odcs_model.model_copy(deep=True) - candidate.schema_[0].properties[0].customProperties = [ # type: ignore[index,union-attr] - CustomProperty(property="lifecycleStatus", value="deprecated") - ] - - result = classify_contract_change(base, candidate) - - assert result.required_bump == "minor" - - -def test_release_classification_returns_major_for_breaking_change(sample_odcs_model): - base = sample_odcs_model.model_copy(deep=True) - candidate = sample_odcs_model.model_copy(deep=True) - candidate.schema_[0].properties[0].required = True # type: ignore[index,union-attr] - - result = classify_contract_change(base, candidate) - - assert result.required_bump == "major" - assert result.breaking_changes - - -def test_release_tag_helpers_parse_and_classify_versions(): - assert parse_release_tag_version("orders/v1.2.3") == "1.2.3" - assert parse_release_tag_version("v2.0.0") == "2.0.0" - assert classify_version_bump("1.0.0", "1.0.1") == "patch" - assert classify_version_bump("1.0.0", "1.1.0") == "minor" - assert classify_version_bump("1.0.0", "2.0.0") == "major" - - -def test_suggest_release_version_uses_last_released_version_not_unreleased_chain(): - assert suggest_release_version("1.2.0", "major") == "2.0.0" - assert suggest_release_version("1.2.0", "minor") == "1.3.0" - assert suggest_release_version("1.2.0", "none") == "1.2.0" - - -def test_prepare_release_candidate_applies_explicit_release_tag(sample_odcs_model): - base = sample_odcs_model.model_copy(deep=True) - candidate = sample_odcs_model.model_copy(deep=True) - candidate.schema_[0].properties.append( # type: ignore[index,union-attr] - SchemaProperty( - name="new_optional_column", - logicalType="string", - physicalType="STRING", - required=False, - ) - ) - - result = prepare_release_candidate(base, candidate, "orders/v1.2.0") - - assert result.current_version == str(base.version) - assert result.target_version == "1.2.0" - assert result.actual_bump == "minor" - assert result.contract.version == "1.2.0" - assert result.contract.id == base.id - - -def test_prepare_release_candidate_rejects_insufficient_bump(sample_odcs_model): - base = sample_odcs_model.model_copy(deep=True) - candidate = sample_odcs_model.model_copy(deep=True) - candidate.schema_[0].properties.append( # type: ignore[index,union-attr] - SchemaProperty( - name="new_optional_column", - logicalType="string", - physicalType="STRING", - required=False, - ) - ) - - with pytest.raises(ValueError, match="requires at least a minor bump"): - prepare_release_candidate(base, candidate, "orders/v1.1.1") - - -def test_prepare_release_candidate_rejects_description_only_changes(sample_odcs_model): - base = sample_odcs_model.model_copy(deep=True) - candidate = sample_odcs_model.model_copy(deep=True) - assert candidate.description is not None - candidate.description.usage = "Updated descriptive text only" - - with pytest.raises(ValueError, match="do not require a release version bump"): - prepare_release_candidate(base, candidate, "orders/v1.1.1") - - From 4453b73d85cf1854b34ff37e2a158c62d8cf3e40 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:03:24 +1000 Subject: [PATCH 409/616] refactor: remove obsolete release workflow artifact --- ...mapact_devops_release_workflow_readable.py | 331 ------------------ 1 file changed, 331 deletions(-) delete mode 100644 tests/test_semapact_devops_release_workflow_readable.py diff --git a/tests/test_semapact_devops_release_workflow_readable.py b/tests/test_semapact_devops_release_workflow_readable.py deleted file mode 100644 index 1ad9cc32..00000000 --- a/tests/test_semapact_devops_release_workflow_readable.py +++ /dev/null @@ -1,331 +0,0 @@ -from __future__ import annotations - -from datetime import date -import json - -from semapact.core.release import prepare_release_candidate -from semapact.devops.pr_creator import AzureDevOpsConfig -from semapact.devops.release_workflow import ( - build_batch_release_manifest, - BatchReleaseTask, - build_release_pr_plan, - classify_contracts_in_repo, - create_release_pull_request, - create_release_pull_requests_from_manifest, -) -from semapact.governance import ChangeContext -from semapact.interfaces import cli -from semapact.utils.yaml_utils import dump_yaml, load_yaml - - -TEST_CONTEXT = ChangeContext(effective_date=date(2026, 1, 1)) - - -def test_build_release_pr_plan_uses_per_contract_defaults(sample_odcs_model): - base = sample_odcs_model.model_copy(deep=True) - candidate = sample_odcs_model.model_copy(deep=True) - assert candidate.schema_ is not None - assert candidate.schema_[0].properties is not None - candidate.schema_[0].properties.append( - candidate.schema_[0] - .properties[0] - .model_copy(update={"name": "new_optional_column", "id": "new_optional_column"}) - ) - - promotion = prepare_release_candidate(base, candidate, "orders/v1.2.0") - plan = build_release_pr_plan( - promotion=promotion, - contract_repo_path="contracts/orders.yaml", - source_branch="release/orders-v1.1.1", - target_branch="release", - ) - - assert plan.contract_id == str(base.id) - assert plan.target_version == "1.2.0" - assert plan.commit_message == f"release({base.id}): prepare 1.2.0" - assert "current version" in plan.description - - -def test_create_release_pull_request_writes_contract_and_calls_pr_creator( - sample_odcs_model, tmp_path, monkeypatch -): - repo_path = tmp_path / "repo" - repo_path.mkdir() - base = sample_odcs_model.model_copy(deep=True) - candidate = sample_odcs_model.model_copy(deep=True) - assert candidate.schema_ is not None - assert candidate.schema_[0].properties is not None - candidate.schema_[0].properties.append( - candidate.schema_[0] - .properties[0] - .model_copy(update={"name": "new_optional_column", "id": "new_optional_column"}) - ) - - captured: dict[str, object] = {} - - def fake_create_update_pr(self, **kwargs): # noqa: ANN001 - captured["kwargs"] = kwargs - return {"pullRequestId": 42} - - monkeypatch.setattr( - "semapact.devops.release_workflow.PullRequestCreator.create_update_pr", - fake_create_update_pr, - ) - - payload = create_release_pull_request( - config=AzureDevOpsConfig( - organization="org", - project="proj", - repository_id="repo", - pat_token="token", - ), - repo_path=str(repo_path), - contract_repo_path="contracts/orders.yaml", - base_contract=base, - candidate_contract=candidate, - release_tag="orders/v1.2.0", - source_branch="release/orders-v1.2.0", - target_branch="release", - context=TEST_CONTEXT, - push=True, - ) - - written = load_yaml(repo_path / "contracts/orders.yaml") - assert payload["pullRequest"]["pullRequestId"] == 42 - assert payload["promotion"]["targetVersion"] == "1.2.0" - assert written["version"] == "1.2.0" - assert written["id"] == str(base.id) - assert captured["kwargs"]["paths"] == ["contracts/orders.yaml"] # type: ignore[index] - assert captured["kwargs"]["push"] is True # type: ignore[index] - - -def test_cli_release_create_pr_outputs_plan_and_pr_payload( - sample_odcs_model, tmp_path, capsys, monkeypatch -): - repo_path = tmp_path / "repo" - repo_path.mkdir() - base = sample_odcs_model.model_copy(deep=True) - candidate = sample_odcs_model.model_copy(deep=True) - assert candidate.schema_ is not None - assert candidate.schema_[0].properties is not None - candidate.schema_[0].properties.append( - candidate.schema_[0] - .properties[0] - .model_copy(update={"name": "new_optional_column", "id": "new_optional_column"}) - ) - - base_path = dump_yaml(base, tmp_path / "base.yaml") - candidate_path = dump_yaml(candidate, tmp_path / "candidate.yaml") - - monkeypatch.setattr( - "semapact.devops.release_workflow.create_release_pull_request", - lambda **kwargs: { - "promotion": {"contractId": str(base.id), "targetVersion": "1.2.0"}, - "pullRequest": {"pullRequestId": 77}, - }, - ) - - monkeypatch.setattr( - "sys.argv", - [ - "semapact", - "release", - "create-pr", - "--base", - str(base_path), - "--candidate", - str(candidate_path), - "--release-tag", - "orders/v1.2.0", - "--repo-path", - str(repo_path), - "--contract-path", - "contracts/orders.yaml", - "--source-branch", - "release/orders-v1.2.0", - "--target-branch", - "release", - "--effective-date", - "2026-01-01", - "--organization", - "org", - "--project", - "proj", - "--repository-id", - "repo", - "--pat-token", - "token", - ], - ) - - exit_code = cli.main() - payload = json.loads(capsys.readouterr().out) - - assert exit_code == 0 - assert payload["pullRequest"]["pullRequestId"] == 77 - assert payload["promotion"]["targetVersion"] == "1.2.0" - - -def test_classify_contracts_in_repo_reports_changed_added_removed_and_unchanged( - sample_odcs_model, tmp_path -): - base_root = tmp_path / "base" - candidate_root = tmp_path / "candidate" - - unchanged = sample_odcs_model.model_copy(deep=True) - changed = sample_odcs_model.model_copy(deep=True) - assert changed.description is not None - changed.description.usage = "Updated descriptive text only" - added = sample_odcs_model.model_copy(deep=True) - added.id = "new-contract" - added.version = "1.0.0" - - dump_yaml(unchanged, base_root / "unchanged.yaml") - dump_yaml(unchanged, candidate_root / "unchanged.yaml") - dump_yaml(sample_odcs_model, base_root / "changed.yaml") - dump_yaml(changed, candidate_root / "changed.yaml") - dump_yaml(sample_odcs_model, base_root / "removed.yaml") - dump_yaml(added, candidate_root / "added.yaml") - - results = classify_contracts_in_repo( - base_root=base_root, - candidate_root=candidate_root, - context=TEST_CONTEXT, - ) - by_path = {item.contract_repo_path: item for item in results} - - assert by_path["unchanged.yaml"].status == "unchanged" - assert by_path["changed.yaml"].status == "changed" - assert by_path["changed.yaml"].required_bump == "none" - assert by_path["changed.yaml"].suggested_release_version is None - assert by_path["added.yaml"].status == "added" - assert by_path["removed.yaml"].status == "removed" - - -def test_create_release_pull_requests_from_manifest_runs_each_contract( - sample_odcs_model, tmp_path, monkeypatch -): - repo_path = tmp_path / "repo" - repo_path.mkdir() - base_a = sample_odcs_model.model_copy(deep=True) - candidate_a = sample_odcs_model.model_copy(deep=True) - assert candidate_a.schema_ is not None - assert candidate_a.schema_[0].properties is not None - candidate_a.schema_[0].properties.append( - candidate_a.schema_[0] - .properties[0] - .model_copy(update={"name": "new_optional_column", "id": "new_optional_column"}) - ) - - base_b = sample_odcs_model.model_copy(deep=True) - base_b.id = "payments" - candidate_b = base_b.model_copy(deep=True) - assert candidate_b.schema_ is not None - assert candidate_b.schema_[0].properties is not None - candidate_b.schema_[0].properties.append( - candidate_b.schema_[0] - .properties[0] - .model_copy(update={"name": "new_optional_column", "id": "new_optional_column"}) - ) - - base_a_path = dump_yaml(base_a, tmp_path / "base-a.yaml") - candidate_a_path = dump_yaml(candidate_a, tmp_path / "candidate-a.yaml") - base_b_path = dump_yaml(base_b, tmp_path / "base-b.yaml") - candidate_b_path = dump_yaml(candidate_b, tmp_path / "candidate-b.yaml") - - calls: list[str] = [] - - def fake_create_update_pr(self, **kwargs): # noqa: ANN001 - calls.append(kwargs["paths"][0]) - return {"pullRequestId": len(calls)} - - monkeypatch.setattr( - "semapact.devops.release_workflow.PullRequestCreator.create_update_pr", - fake_create_update_pr, - ) - - results = create_release_pull_requests_from_manifest( - config=AzureDevOpsConfig( - organization="org", - project="proj", - repository_id="repo", - pat_token="token", - ), - repo_path=str(repo_path), - tasks=[ - BatchReleaseTask( - base=str(base_a_path), - candidate=str(candidate_a_path), - contract_path="contracts/orders.yaml", - release_tag="orders/v1.2.0", - source_branch="release/orders-v1.2.0", - target_branch="release", - effective_date="2026-01-01", - ), - BatchReleaseTask( - base=str(base_b_path), - candidate=str(candidate_b_path), - contract_path="contracts/payments.yaml", - release_tag="payments/v1.2.0", - source_branch="release/payments-v1.2.0", - target_branch="release", - effective_date="2026-01-01", - ), - ], - push=False, - ) - - assert len(results) == 2 - assert calls == ["contracts/orders.yaml", "contracts/payments.yaml"] - - -def test_build_batch_release_manifest_generates_editable_tasks_and_skips_manual_cases( - sample_odcs_model, tmp_path -): - base_root = tmp_path / "base" - candidate_root = tmp_path / "candidate" - - unchanged = sample_odcs_model.model_copy(deep=True) - docs_only = sample_odcs_model.model_copy(deep=True) - assert docs_only.description is not None - docs_only.description.usage = "Updated descriptive text only" - - additive = sample_odcs_model.model_copy(deep=True) - assert additive.schema_ is not None - assert additive.schema_[0].properties is not None - additive.schema_[0].properties.append( - additive.schema_[0] - .properties[0] - .model_copy(update={"name": "new_optional_column", "id": "new_optional_column"}) - ) - - added = sample_odcs_model.model_copy(deep=True) - added.id = "new-contract" - added.version = "1.0.0" - - dump_yaml(unchanged, base_root / "unchanged.yaml") - dump_yaml(unchanged, candidate_root / "unchanged.yaml") - dump_yaml(sample_odcs_model, base_root / "docs.yaml") - dump_yaml(docs_only, candidate_root / "docs.yaml") - dump_yaml(sample_odcs_model, base_root / "additive.yaml") - dump_yaml(additive, candidate_root / "additive.yaml") - dump_yaml(sample_odcs_model, base_root / "removed.yaml") - dump_yaml(added, candidate_root / "added.yaml") - - build = build_batch_release_manifest( - base_root=base_root, - candidate_root=candidate_root, - context=TEST_CONTEXT, - ) - tasks_by_path = {task.contract_path: task for task in build.tasks} - skipped_by_path = {item.contract_repo_path: item for item in build.skipped} - - assert tasks_by_path["additive.yaml"].release_tag.endswith("/v1.2.0") - assert tasks_by_path["additive.yaml"].target_branch == "release" - assert tasks_by_path["additive.yaml"].effective_date == "2026-01-01" - assert "docs.yaml" in skipped_by_path - assert skipped_by_path["docs.yaml"].status == "changed" - assert "unchanged.yaml" in skipped_by_path - assert skipped_by_path["unchanged.yaml"].status == "unchanged" - assert skipped_by_path["added.yaml"].status == "added" - assert skipped_by_path["removed.yaml"].status == "removed" From e2d7f21a39aab840c6311f8131a90e69b524a0ce Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:03:27 +1000 Subject: [PATCH 410/616] refactor: remove obsolete release workflow artifact --- examples/release/release-manifest.example.json | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 examples/release/release-manifest.example.json diff --git a/examples/release/release-manifest.example.json b/examples/release/release-manifest.example.json deleted file mode 100644 index f624e56f..00000000 --- a/examples/release/release-manifest.example.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "base": "contracts-main/orders.yaml", - "candidate": "contracts-release/orders.yaml", - "contract_path": "contracts/orders.yaml", - "release_tag": "orders/v1.2.0", - "source_branch": "release/orders-v1.2.0", - "target_branch": "release" - }, - { - "base": "contracts-main/payments.yaml", - "candidate": "contracts-release/payments.yaml", - "contract_path": "contracts/payments.yaml", - "release_tag": "payments/v2.0.0", - "source_branch": "release/payments-v2.0.0", - "target_branch": "release" - } -] From 7a638d257ff0d2ab26f1e284ac087e64a9bd6d2b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:03:29 +1000 Subject: [PATCH 411/616] refactor: remove obsolete release workflow artifact --- examples/ci/release.example.sh | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 examples/ci/release.example.sh diff --git a/examples/ci/release.example.sh b/examples/ci/release.example.sh deleted file mode 100644 index 07214aaf..00000000 --- a/examples/ci/release.example.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Example release flow for a multi-contract repo. -# Review the generated manifest before creating PRs. - -BASE_ROOT="${BASE_ROOT:-./contracts-main}" -CANDIDATE_ROOT="${CANDIDATE_ROOT:-./contracts-release}" -MANIFEST_PATH="${MANIFEST_PATH:-./artifacts/release_manifest.json}" -REPO_PATH="${REPO_PATH:-.}" - -: "${ADO_ORGANIZATION:?Set ADO_ORGANIZATION}" -: "${ADO_PROJECT:?Set ADO_PROJECT}" -: "${ADO_REPOSITORY_ID:?Set ADO_REPOSITORY_ID}" -: "${ADO_PAT_TOKEN:?Set ADO_PAT_TOKEN}" - -semapact release build-manifest \ - --base-root "${BASE_ROOT}" \ - --candidate-root "${CANDIDATE_ROOT}" \ - --output "${MANIFEST_PATH}" - -echo "Review and edit ${MANIFEST_PATH} before continuing." - -semapact release create-prs \ - --manifest "${MANIFEST_PATH}" \ - --repo-path "${REPO_PATH}" \ - --organization "${ADO_ORGANIZATION}" \ - --project "${ADO_PROJECT}" \ - --repository-id "${ADO_REPOSITORY_ID}" \ - --pat-token "${ADO_PAT_TOKEN}" \ - --push From f45d6d4d0f34ba753a5bc25d4741893e22fc283c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:03:32 +1000 Subject: [PATCH 412/616] refactor: remove obsolete release workflow artifact --- examples/azure-devops/semapact-release.yml | 42 ---------------------- 1 file changed, 42 deletions(-) delete mode 100644 examples/azure-devops/semapact-release.yml diff --git a/examples/azure-devops/semapact-release.yml b/examples/azure-devops/semapact-release.yml deleted file mode 100644 index bb219fe8..00000000 --- a/examples/azure-devops/semapact-release.yml +++ /dev/null @@ -1,42 +0,0 @@ -trigger: none - -pool: - vmImage: ubuntu-latest - -variables: - UV_NO_PROGRESS: "1" - MANIFEST_PATH: "./artifacts/release_manifest.json" - -steps: - - checkout: self - - - task: UsePythonVersion@0 - inputs: - versionSpec: "3.11" - - - script: | - python -m pip install uv - uv sync --all-extras --group dev --frozen - displayName: Install dependencies - - - script: | - semapact release build-manifest \ - --base-root ./contracts-main \ - --candidate-root ./contracts-release \ - --output "$(MANIFEST_PATH)" - displayName: Build editable per-contract release manifest - - - script: | - cat "$(MANIFEST_PATH)" - displayName: Review manifest output - - - script: | - semapact release create-prs \ - --manifest "$(MANIFEST_PATH)" \ - --repo-path . \ - --organization "$(ADO_ORGANIZATION)" \ - --project "$(ADO_PROJECT)" \ - --repository-id "$(ADO_REPOSITORY_ID)" \ - --pat-token "$(ADO_PAT_TOKEN)" \ - --push - displayName: Create per-contract release PRs From 0a3038741e598cb7be27de001037f1da9d746325 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:03:40 +1000 Subject: [PATCH 413/616] refactor(devops): remove obsolete release workflow exports --- semapact/devops/__init__.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/semapact/devops/__init__.py b/semapact/devops/__init__.py index 5a3fd63d..1a7dac6d 100644 --- a/semapact/devops/__init__.py +++ b/semapact/devops/__init__.py @@ -1,19 +1,6 @@ from semapact.devops.audit import AuditMetadata, build_audit_metadata from semapact.devops.ci_cd import CIDecision, evaluate_ci_gate, write_ci_summary from semapact.devops.pr_creator import AzureDevOpsConfig, PullRequestCreator -from semapact.devops.release_workflow import ( - BatchReleaseManifestBuild, - BatchReleaseTask, - ReleasePullRequestPlan, - RepositoryContractChange, - batch_manifest_build_to_dict, - build_batch_release_manifest, - build_release_pr_plan, - create_release_pull_request, - create_release_pull_requests_from_manifest, - load_batch_release_tasks, - repository_change_to_dict, -) __all__ = [ "AuditMetadata", @@ -23,15 +10,4 @@ "write_ci_summary", "AzureDevOpsConfig", "PullRequestCreator", - "BatchReleaseManifestBuild", - "BatchReleaseTask", - "ReleasePullRequestPlan", - "RepositoryContractChange", - "batch_manifest_build_to_dict", - "build_batch_release_manifest", - "build_release_pr_plan", - "create_release_pull_request", - "create_release_pull_requests_from_manifest", - "load_batch_release_tasks", - "repository_change_to_dict", ] From d3611226422c8d22f790566ed05e0c5637f4568b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:04:04 +1000 Subject: [PATCH 414/616] refactor(examples): remove obsolete release promotion workflow --- examples/github/semapact-release.yml | 59 ---------------------------- 1 file changed, 59 deletions(-) delete mode 100644 examples/github/semapact-release.yml diff --git a/examples/github/semapact-release.yml b/examples/github/semapact-release.yml deleted file mode 100644 index 8c40cd1a..00000000 --- a/examples/github/semapact-release.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Release Contracts - -on: - workflow_dispatch: - inputs: - base_root: - description: 'Directory path for base contracts' - required: true - default: './contracts-main' - candidate_root: - description: 'Directory path for release candidates' - required: true - default: './contracts-release' - -jobs: - build-and-release: - runs-on: ubuntu-latest - env: - UV_NO_PROGRESS: "1" - SEMAPACT_RUNTIME_CONTEXT: "auto" - SEMAPACT_GIT_PROVIDER: "github" - SEMAPACT_GITHUB_OWNER: ${{ github.repository_owner }} - SEMAPACT_GITHUB_REPO: ${{ github.event.repository.name }} - SEMAPACT_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Set up uv - uses: astral-sh/setup-uv@v5 - - - name: Install dependencies - run: uv sync --all-extras --group dev --frozen - - - name: Build Release Manifest - run: | - mkdir -p artifacts - uv run semapact release build-manifest \ - --base-root ${{ github.event.inputs.base_root || './contracts-main' }} \ - --candidate-root ${{ github.event.inputs.candidate_root || './contracts-release' }} \ - --output ./artifacts/release_manifest.json - - - name: Upload Manifest Artifact - uses: actions/upload-artifact@v4 - with: - name: release_manifest - path: ./artifacts/release_manifest.json - - - name: Create Release PRs - run: | - uv run semapact release create-prs \ - --manifest ./artifacts/release_manifest.json \ - --repo-path . \ - --push From 8d354845e034f9aacd1335d9e42c09cabdc257b0 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:04:21 +1000 Subject: [PATCH 415/616] test(architecture): remove services compatibility assertions --- tests/test_application_architecture.py | 27 ++++++-------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/tests/test_application_architecture.py b/tests/test_application_architecture.py index df72b707..d2a97823 100644 --- a/tests/test_application_architecture.py +++ b/tests/test_application_architecture.py @@ -8,17 +8,6 @@ from semapact.application.services.reconciliation import ReconciliationService from semapact.application.services.release_planning import ReleasePlanningService from semapact.application.services.version_authority import VersionAuthorityService -from semapact.services import ( - DeploymentService as LegacyDeploymentService, - GovernanceAnalysis as LegacyGovernanceAnalysis, - GovernanceProposal as LegacyGovernanceProposal, - GovernanceService as LegacyGovernanceService, - ReconciliationService as LegacyReconciliationService, - ReleasePlanningResult as LegacyReleasePlanningResult, - ReleasePlanningService as LegacyReleasePlanningService, - RuntimeReconciliation as LegacyRuntimeReconciliation, - VersionAuthorityService as LegacyVersionAuthorityService, -) def test_application_result_models_have_explicit_model_ownership() -> None: @@ -28,13 +17,9 @@ def test_application_result_models_have_explicit_model_ownership() -> None: assert ReleasePlanningResult.__module__ == "semapact.application.models.release" -def test_legacy_services_package_is_compatibility_only() -> None: - assert LegacyGovernanceAnalysis is GovernanceAnalysis - assert LegacyGovernanceProposal is GovernanceProposal - assert LegacyRuntimeReconciliation is RuntimeReconciliation - assert LegacyReleasePlanningResult is ReleasePlanningResult - assert LegacyGovernanceService is GovernanceService - assert LegacyReconciliationService is ReconciliationService - assert LegacyDeploymentService is DeploymentService - assert LegacyReleasePlanningService is ReleasePlanningService - assert LegacyVersionAuthorityService is VersionAuthorityService +def test_application_services_have_single_canonical_package() -> None: + assert DeploymentService.__module__ == "semapact.application.services.deployment" + assert GovernanceService.__module__ == "semapact.application.services.governance" + assert ReconciliationService.__module__ == "semapact.application.services.reconciliation" + assert ReleasePlanningService.__module__ == "semapact.application.services.release_planning" + assert VersionAuthorityService.__module__ == "semapact.application.services.version_authority" From 638aaf865ff2a65edddcae5d1ed0f9ec17cf69ad Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:04:24 +1000 Subject: [PATCH 416/616] test(examples): keep only canonical release workflows --- .../test_semapact_devops_examples_readable.py | 44 ++++--------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/tests/test_semapact_devops_examples_readable.py b/tests/test_semapact_devops_examples_readable.py index 27f04326..3387813f 100644 --- a/tests/test_semapact_devops_examples_readable.py +++ b/tests/test_semapact_devops_examples_readable.py @@ -1,47 +1,22 @@ from __future__ import annotations -import json from pathlib import Path import yaml -def test_release_manifest_example_is_valid_json_array(): - manifest_path = Path("examples/release/release-manifest.example.json") - payload = json.loads(manifest_path.read_text(encoding="utf-8")) - - assert isinstance(payload, list) - assert payload - assert { - "base", - "candidate", - "contract_path", - "release_tag", - "source_branch", - "target_branch", - } <= set(payload[0]) - - -def test_ci_shell_examples_reference_release_commands(): - pr_example = Path("examples/ci/pr-check.example.sh").read_text(encoding="utf-8") - release_example = Path("examples/ci/release.example.sh").read_text(encoding="utf-8") - - assert "release classify-repo" in pr_example - assert "release build-manifest" in release_example - assert "release create-prs" in release_example - - -def test_azure_devops_examples_reference_release_commands(): - pr_pipeline = Path("examples/azure-devops/semapact-pr-validation.yml").read_text( - encoding="utf-8" - ) - release_pipeline = Path("examples/azure-devops/semapact-release.yml").read_text( +def test_pr_validation_examples_use_repository_classification() -> None: + shell = Path("examples/ci/pr-check.example.sh").read_text(encoding="utf-8") + azure = Path("examples/azure-devops/semapact-pr-validation.yml").read_text( encoding="utf-8" ) - assert "release classify-repo" in pr_pipeline - assert "release build-manifest" in release_pipeline - assert "release create-prs" in release_pipeline + assert "release classify-repo" in shell + assert "release classify-repo" in azure + assert "build-manifest" not in shell + assert "create-prs" not in shell + assert "build-manifest" not in azure + assert "create-prs" not in azure def test_data_product_github_example_uses_bundle_driven_ci_cd() -> None: @@ -59,7 +34,6 @@ def test_data_product_github_example_uses_bundle_driven_ci_cd() -> None: assert "environment: contract-release" in workflow assert "environment: production" in workflow assert "--operational-history" not in workflow - assert "\n --release \\" not in workflow def test_central_contract_repo_github_example_fans_out_and_commits_ledger_once() -> None: From 66c8776c40abc6d84e72bb76e74fee4ce492e0e7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:04:27 +1000 Subject: [PATCH 417/616] docs(examples): remove obsolete release-promotion examples --- examples/README.md | 65 +++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/examples/README.md b/examples/README.md index c99ef449..e209d75e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,52 +2,45 @@ This folder contains reference assets for wiring SemaPact into CI/CD. -## Release Assets - -- `release/release-manifest.example.json` - - example per-contract batch release manifest - -## CI Shell Examples +## Pull-request validation - `ci/pr-check.example.sh` - - single-contract and multi-contract PR build examples -- `ci/release.example.sh` - - multi-contract release build example + - repository-level contract classification for PR validation +- `azure-devops/semapact-pr-validation.yml` + - the same classification pattern in Azure DevOps -## GitHub Actions Examples +These examples use `semapact release classify-repo` only to discover which governed +contracts changed. They do not create release branches, manifests, version bumps, or +pull requests. + +## Canonical GitHub Actions CI/CD - `github/data-product-ci-cd.yml` - - end-to-end candidate CI + formal-release CD for a standard data-product repo + - end-to-end candidate CI + formal release + production deployment for a standard + data-product repo - `github/central-contract-repo-ci-cd.yml` - - changed-contract discovery, matrix assessment/deployment, and single-write governance ledger for a centralised contract repo + - changed-contract discovery, matrix release finalization, immutable artifact handoff, + target fan-out, and a single governance-ledger write for a central contract repo - `github/semapact-enrich.yml` - on-demand LLM contract enrichment trigger -- `github/semapact-release.yml` - - release-promotion example - -## Azure DevOps Examples - -- `azure-devops/semapact-pr-validation.yml` - - PR validation template -- `azure-devops/semapact-release.yml` - - release promotion template - -Important rules reflected by these examples: - -- version governance is per contract, not per repo -- PR builds classify changes but do not bump contract versions -- release builds apply explicit release tags only for contracts that require a bump -- contracts with `required_bump = none` are skipped by default in batch release manifests - - -## Bundle-driven CI/CD examples -The two bundle-driven GitHub examples intentionally use only public SemaPact CLI surfaces plus ordinary GitHub Actions/Git commands. They are also architecture smoke tests: any large amount of JSON extraction or repository glue is a signal that the CLI boundary may need simplification. +The two bundle-driven CI/CD examples intentionally use only public SemaPact CLI surfaces +plus ordinary GitHub Actions/Git commands. They are architecture fitness tests: awkward +JSON extraction, internal IDs, Git round-trips, or duplicate approval plumbing indicate +a CLI/application-boundary problem. -Both examples assume contracts define Databricks servers named `development` and `production`; adapt those names to local contract conventions. +Both CI/CD examples assume contracts define Databricks servers named `development` and +`production`; adapt those names to local contract conventions. -Operational deployment history is config-first through `.semapact.yaml`. The examples do not repeat `--operational-history` on every deployment. +Operational deployment history is config-first through `.semapact.yaml`. The examples +do not repeat `--operational-history` on every deployment. -The examples use two distinct GitHub Environment boundaries. `contract-release` supplies human PUBLISH approval for REVIEW releases, recorded with `semapact release approve`. `production` protects whether the deployment job may run; SemaPact does not convert the finalized ContractRelease into DEPLOY authorization. +The examples use two distinct GitHub Environment boundaries. `contract-release` supplies +human PUBLISH approval for REVIEW releases, recorded with `semapact release approve`. +`production` protects whether the deployment job may run; SemaPact does not convert a +finalized ContractRelease into DEPLOY authorization. -`release finalize --release-out` publishes the immutable ContractRelease as a pipeline artifact. Deployment jobs consume that artifact directly with `deployment assess --release`; Git history remains the governance ledger rather than the job-to-job transport. +`release finalize --release-out` publishes the immutable ContractRelease as a pipeline +artifact. Deployment jobs consume that artifact directly with `deployment assess +--release`; Git history remains the governance ledger rather than the job-to-job +transport. From 1d5641c5e8520249d3c1e33b00e7464e10ce4e08 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:04:42 +1000 Subject: [PATCH 418/616] docs: describe greenfield canonical architecture --- README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 27b3c98c..92464ed3 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ SemaPact currently supports deterministic change analysis and lifecycle-aware po - relationship change handling; - version-policy classification; - deterministic `GovernanceDecision` artifacts; -- centralized governance and authorization boundaries for planning, publication, and deployment operations. +- centralized governance and release-approval boundaries, with runtime deployment permission delegated to the protected execution environment. ### Canonical ContractOps release planning @@ -472,16 +472,15 @@ Optional extras are intentionally separate from the base distribution. If an int ```text semapact/ - core/ # loading, validation, compatibility workflow boundaries + core/ # loading, configuration and validation lifecycle/ # canonical identity, lifecycle and change policy governance/ # GovernanceDecision and centralized gate - contractops/ # deterministic release planning / authorization / apply / publish domain - deployment/ # provider-neutral deployment plans, authorization, preview contracts + contractops/ # deterministic release planning, approval and release artifacts + deployment/ # provider-neutral deployment source, plan and preview contracts runtime/ # provider-neutral governed runtime asset projection application/ # interface-independent use-case models + orchestration models/ # application result DTOs; no domain authority services/ # thin orchestration over canonical domain rules/ports - services/ # backward-compatible imports only observation/ # platform-neutral observed state + fingerprint reconciliation/ # governed desired vs observed comparison platforms/ # provider adapters such as Databricks @@ -489,12 +488,12 @@ semapact/ exporters/ # SQL / graph and other outputs quality/ # quality intent adapters interfaces/ # CLI and user-facing boundaries - devops/ # Git / CI compatibility helpers + devops/ # Git / CI integration helpers ``` A central architectural rule is: -> **Interfaces parse and render. Application services orchestrate. Domain packages own business meaning. Platform adapters own provider-specific effects. Compatibility packages do not become new owners.** +> **Interfaces parse and render. Application services orchestrate. Domain packages own business meaning. Platform adapters own provider-specific effects. There is one canonical workflow per lifecycle.** See [`ARCHITECTURE.md`](ARCHITECTURE.md) for package/model placement rules. From 9b30054fb048f56a49ca09c584b7d9a7fbedd2db Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:06:12 +1000 Subject: [PATCH 419/616] refactor(lifecycle): remove root legacy status fallback --- semapact/lifecycle/status.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/semapact/lifecycle/status.py b/semapact/lifecycle/status.py index 8d92f4dc..cfc9a0e1 100644 --- a/semapact/lifecycle/status.py +++ b/semapact/lifecycle/status.py @@ -37,10 +37,8 @@ class LifecycleStatus(StrEnum): def normalize_status(value: Any) -> LifecycleStatus: """Normalize status string to canonical LifecycleStatus enum. - Supported values & aliases: - - 'draft', 'proposed' -> LifecycleStatus.DRAFT - (Note: 'proposed' is a read-only governance interpretation alias; - SemaPact does not rewrite ODCS YAML status during resolution) + Supported values: + - 'draft' -> LifecycleStatus.DRAFT - 'active' -> LifecycleStatus.ACTIVE - 'deprecated' -> LifecycleStatus.DEPRECATED - 'retired' -> LifecycleStatus.RETIRED @@ -58,7 +56,7 @@ def normalize_status(value: Any) -> LifecycleStatus: if not text: raise ValueError("Lifecycle status value cannot be empty") - if text in ("draft", "proposed"): + if text == "draft": return LifecycleStatus.DRAFT if text == "active": return LifecycleStatus.ACTIVE @@ -109,10 +107,9 @@ def resolve_contract_lifecycle( ) -> LifecycleStatus: """Resolve authoritative contract root lifecycle status. - Fallback order: + Resolution: 1. contract.status (ODCS native canonical root status) - 2. contract.customProperties.lifecycleStatus (legacy fallback) - 3. LifecycleStatus.DRAFT (canonical default) + 2. LifecycleStatus.DRAFT when root status is absent or invalid Note: Lifecycle resolvers are intentionally total for deterministic analysis; invalid statuses fall back to DRAFT while lifecycle validity is enforced by ContractValidator. @@ -121,7 +118,7 @@ def resolve_contract_lifecycle( return LifecycleStatus.DRAFT - # 1. Native root status + # Native root status raw_status = contract.status if raw_status is not None and str(raw_status).strip() != "": try: @@ -129,12 +126,6 @@ def resolve_contract_lifecycle( except ValueError: return LifecycleStatus.DRAFT - # 2. Legacy customProperties fallback - declared = lifecycle_from_custom_properties(contract.customProperties) - if declared is not None: - return declared - - # 3. Canonical default return LifecycleStatus.DRAFT From 8a7d373407b7608db33d2aa763cc09b8b0294f39 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:06:15 +1000 Subject: [PATCH 420/616] refactor(lifecycle): remove deprecated compatibility helpers --- semapact/lifecycle/helpers.py | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/semapact/lifecycle/helpers.py b/semapact/lifecycle/helpers.py index 73b03bcc..00e47b10 100644 --- a/semapact/lifecycle/helpers.py +++ b/semapact/lifecycle/helpers.py @@ -23,7 +23,6 @@ __all__ = [ "LIFECYCLE_STATUS_PROPERTY", "LifecycleStatus", - "allows_breaking_changes", "decimal_precision_reduction", "decimal_scale_reduction", "is_active_contract", @@ -45,34 +44,6 @@ def schema_items(contract: OpenDataContractStandard) -> list[Any]: return list(contract.schema_ or []) -def allows_breaking_changes(entity: Any) -> bool: - """Deprecated compatibility wrapper: returns True if entity participates in breaking checks.""" - import warnings - - warnings.warn( - "allows_breaking_changes is deprecated; use participates_in_breaking_checks(resolve_*_lifecycle(...)) instead.", - DeprecationWarning, - stacklevel=2, - ) - if isinstance(entity, OpenDataContractStandard): - return participates_in_breaking_checks(resolve_contract_lifecycle(entity)) - declared = resolve_declared_entity_lifecycle(entity) - if declared is not None: - return participates_in_breaking_checks(declared) - declared_val = getattr(entity, "lifecycleStatus", None) - if declared_val is not None: - try: - return participates_in_breaking_checks(normalize_status(declared_val)) - except ValueError: - return False - return True - - - -# Keep private alias for backwards compatibility -_lifecycle_from_custom_properties = lifecycle_from_custom_properties - - # --------------------------------------------------------------------------- From 2a1f9e35079a5edc06232fdb08d65561d652f7d0 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:06:18 +1000 Subject: [PATCH 421/616] refactor(editor): remove lifecycle compatibility alias --- semapact/core/editor_semantics.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/semapact/core/editor_semantics.py b/semapact/core/editor_semantics.py index 4ea5ac20..ec62d2f6 100644 --- a/semapact/core/editor_semantics.py +++ b/semapact/core/editor_semantics.py @@ -168,10 +168,6 @@ def field_declared_lifecycle_status(field_obj: PropertyInput) -> str: return "" -# Compatibility alias -field_lifecycle_status = field_declared_lifecycle_status - - def field_option_label(field_obj: PropertyInput, index: int) -> str: """Format a field label for display.""" field_name = str(_property_model(field_obj).name or "").strip() From 07bd34e5510a4329302d06865210287d502159b8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:06:22 +1000 Subject: [PATCH 422/616] fix(lifecycle): reference canonical release workflow --- semapact/lifecycle/policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/lifecycle/policy.py b/semapact/lifecycle/policy.py index bde5dcf0..16c15b17 100644 --- a/semapact/lifecycle/policy.py +++ b/semapact/lifecycle/policy.py @@ -102,7 +102,7 @@ def evaluate_merge_policy( brk = BreakingChange( code=GovernanceReasonCode.CONTRACT_VERSION_MANUALLY_CHANGED, path="version", - message="Contract version mismatch. Contract versions are release-managed and cannot be manually updated during normal import/merge. Please revert the version change and use 'semapact release prepare'.", + message="Contract version mismatch. Contract versions are release-managed and cannot be manually updated during normal import/merge. Please revert the version change; SemaPact sets the release version during 'semapact release finalize'.", ) change_breaks.append(brk) change_reasons.append(brk.code) From 24085806b9ccf5d8f78c5b7f71da95399e1a0838 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:07:01 +1000 Subject: [PATCH 423/616] refactor(lifecycle): remove deprecated public helper --- semapact/lifecycle/__init__.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/semapact/lifecycle/__init__.py b/semapact/lifecycle/__init__.py index 7045adb5..b75523ab 100644 --- a/semapact/lifecycle/__init__.py +++ b/semapact/lifecycle/__init__.py @@ -1,7 +1,4 @@ -from semapact.lifecycle.helpers import ( - allows_breaking_changes, - schema_items, -) +from semapact.lifecycle.helpers import schema_items from semapact.lifecycle.status import ( LIFECYCLE_STATUS_PROPERTY, LifecycleStatus, @@ -61,7 +58,6 @@ "is_retired_contract", "participates_in_breaking_checks", "is_explicitly_deprecated", - "allows_breaking_changes", "schema_items", "SchemaIdentity", From f1303dd595b234d0bdf487cdbccb2fcab70e4170 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:07:04 +1000 Subject: [PATCH 424/616] test(lifecycle): enforce canonical root lifecycle status --- tests/test_lifecycle_status_resolver.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_lifecycle_status_resolver.py b/tests/test_lifecycle_status_resolver.py index 91ec9083..b8959053 100644 --- a/tests/test_lifecycle_status_resolver.py +++ b/tests/test_lifecycle_status_resolver.py @@ -28,12 +28,10 @@ def _cp(key: str, value: str) -> CustomProperty: return CustomProperty(property=key, value=value) -def test_normalize_status_valid_and_aliases(): +def test_normalize_status_valid_values(): assert normalize_status("draft") is LifecycleStatus.DRAFT assert normalize_status("DRAFT") is LifecycleStatus.DRAFT assert normalize_status(" draft ") is LifecycleStatus.DRAFT - assert normalize_status("proposed") is LifecycleStatus.DRAFT - assert normalize_status("PROPOSED") is LifecycleStatus.DRAFT assert normalize_status("active") is LifecycleStatus.ACTIVE assert normalize_status("Active") is LifecycleStatus.ACTIVE assert normalize_status("deprecated") is LifecycleStatus.DEPRECATED @@ -54,6 +52,9 @@ def test_normalize_status_invalid_inputs(): with pytest.raises(ValueError, match="Unknown lifecycle status: 'invalid_val'"): normalize_status("invalid_val") + with pytest.raises(ValueError, match="Unknown lifecycle status: 'proposed'"): + normalize_status("proposed") + def test_lifecycle_from_custom_properties(): assert lifecycle_from_custom_properties(None) is None @@ -92,7 +93,7 @@ def test_resolve_contract_lifecycle_precedence(): ) assert resolve_contract_lifecycle(c1) is LifecycleStatus.ACTIVE - # 2. Legacy customProperties fallback when root status is missing + # Root customProperties do not define contract lifecycle. c2 = OpenDataContractStandard( apiVersion="v3.1.0", kind="DataContract", @@ -100,9 +101,9 @@ def test_resolve_contract_lifecycle_precedence(): version="1.0.0", customProperties=[_cp("lifecycleStatus", "deprecated")], ) - assert resolve_contract_lifecycle(c2) is LifecycleStatus.DEPRECATED + assert resolve_contract_lifecycle(c2) is LifecycleStatus.DRAFT - # 3. Canonical default when neither is provided + # Canonical default when status is absent c3 = OpenDataContractStandard( apiVersion="v3.1.0", kind="DataContract", From a2cabe10bde3648ac38e68b1640656dbd873eca2 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:07:08 +1000 Subject: [PATCH 425/616] test(lifecycle): remove compatibility lifecycle paths --- .../test_lifecycle_fail_closed_governance.py | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/tests/test_lifecycle_fail_closed_governance.py b/tests/test_lifecycle_fail_closed_governance.py index 86ce0310..93283f39 100644 --- a/tests/test_lifecycle_fail_closed_governance.py +++ b/tests/test_lifecycle_fail_closed_governance.py @@ -12,9 +12,8 @@ from semapact.governance.evaluator import evaluate_governance_decision from semapact.governance.models import DecisionResult from semapact.governance_codes import GovernanceReasonCode -from semapact.lifecycle.helpers import allows_breaking_changes from semapact.lifecycle.merge_engine import ContractMergeEngine -from semapact.services.governance_service import GovernanceService +from semapact.application.services.governance import GovernanceService TEST_CONTEXT = ChangeContext(effective_date=date(2026, 8, 14)) @@ -193,18 +192,18 @@ def test_merge_engine_detects_conflict_when_source_status_missing(): assert any(c.rule == "physical_type_change" for c in result.conflicts) -def test_merge_engine_auto_deprecates_when_source_status_proposed_or_missing(): +def test_merge_engine_auto_deprecates_when_source_status_draft(): """Active governed target + source missing an active property -> auto-deprecation must occur.""" engine = ContractMergeEngine() target = _base_active_contract() # status: active - # Source has status="proposed" and is missing "customer" property + # Source is draft and is missing "customer" property source = OpenDataContractStandard( apiVersion="v3.1.0", kind="DataContract", id="orders_contract", version="1.0.0", - status="proposed", + status="draft", schema=[ SchemaObject( name="orders", @@ -235,20 +234,3 @@ def test_merge_engine_auto_deprecates_when_source_status_proposed_or_missing(): assert cp_map.get("lifecycleStatus") == "deprecated" assert cp_map.get("semapact.removed") == "true" - -def test_allows_breaking_changes_compatibility_wrapper(): - active_contract = _base_active_contract() - draft_contract = active_contract.model_copy(deep=True) - draft_contract.status = "draft" - - assert allows_breaking_changes(active_contract) is True - assert allows_breaking_changes(draft_contract) is False - - active_prop = SchemaProperty( - name="col", customProperties=[_cp("lifecycleStatus", "active")] - ) - deprecated_prop = SchemaProperty( - name="col", customProperties=[_cp("lifecycleStatus", "deprecated")] - ) - assert allows_breaking_changes(active_prop) is True - assert allows_breaking_changes(deprecated_prop) is False From c9c6c55fbeca2afee98fdb98cfdcf1c803f9d9a6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:07:11 +1000 Subject: [PATCH 426/616] test(lifecycle): remove proposed status alias --- tests/test_lifecycle_effective_scope_matrix.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/test_lifecycle_effective_scope_matrix.py b/tests/test_lifecycle_effective_scope_matrix.py index 30f9da8a..7e51c656 100644 --- a/tests/test_lifecycle_effective_scope_matrix.py +++ b/tests/test_lifecycle_effective_scope_matrix.py @@ -121,17 +121,6 @@ def _cp(key: str, value: str) -> CustomProperty: LifecycleStatus.DEPRECATED, False, ), - # 9. Proposed contract alias: interpreted as draft - ( - "proposed", - None, - None, - None, - LifecycleStatus.DRAFT, - LifecycleStatus.DRAFT, - LifecycleStatus.DRAFT, - False, - ), ], ) def test_lifecycle_effective_scope_matrix( From d79a11aad3a7243883e370a64221544f6e1f3c6d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:07:15 +1000 Subject: [PATCH 427/616] test(editor): use canonical editor semantics directly --- tests/test_semapact_editor_contract_readable.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/test_semapact_editor_contract_readable.py b/tests/test_semapact_editor_contract_readable.py index 049a06da..cd2b8f0f 100644 --- a/tests/test_semapact_editor_contract_readable.py +++ b/tests/test_semapact_editor_contract_readable.py @@ -6,13 +6,11 @@ SchemaProperty, ) -from semapact.core.editor_contract import ( +from semapact.core.editor_semantics import ( contract_description_part, contract_tags, + field_declared_lifecycle_status, field_examples_text, - field_lifecycle_status, -) -from semapact.core.editor_semantics import ( contract_api_version, contract_data_product, contract_domain, @@ -96,8 +94,8 @@ def test_field_helpers_support_odcs_model_and_ui_working_copy(): "customProperties": [{"property": "lifecycleStatus", "value": "deprecated"}], } - assert field_lifecycle_status(field_model) == "active" - assert field_lifecycle_status(field_dict) == "deprecated" + assert field_declared_lifecycle_status(field_model) == "active" + assert field_declared_lifecycle_status(field_dict) == "deprecated" assert field_examples_text(field_model) == "a\nb" assert field_examples_text(field_dict) == "c\nd" From 151f8cd41e303d8c85fe271c336f0c906733910d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:07:21 +1000 Subject: [PATCH 428/616] refactor(editor): remove compatibility facade --- semapact/core/editor_contract.py | 74 -------------------------------- 1 file changed, 74 deletions(-) delete mode 100644 semapact/core/editor_contract.py diff --git a/semapact/core/editor_contract.py b/semapact/core/editor_contract.py deleted file mode 100644 index 0827a634..00000000 --- a/semapact/core/editor_contract.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Compatibility facade for editor helpers. - -New code should import from: -- `semapact.core.editor_semantics` -- `semapact.core.editor_rows` - -This module remains as a stable compatibility layer while the editor codebase -is being split into ODCS-aware semantics and dict-based UI row adapters. -""" - -from __future__ import annotations - -from semapact.core.editor_rows import ( - add_field, - add_quality_rule, - apply_field_detail, - apply_quality_rows, - apply_quick_field_rows, - field_by_name, - is_blank_quality_row, - is_blank_quick_field_row, - optional_int, - quality_rows, - rule_condition, - selected_schema_field_names, - tags_to_text, - text_to_tags, -) -from semapact.core.editor_semantics import ( - contract_description_part, - contract_tags, - description_mapping, - field_declared_lifecycle_status, - field_examples_text, - field_lifecycle_status, - normalize_tags, - schema_items, - set_contract_description_part, - set_contract_tags_list, - set_field_examples, - set_field_lifecycle_status, - set_mapping_text, -) - -__all__ = [ - "add_field", - "add_quality_rule", - "apply_field_detail", - "apply_quality_rows", - "apply_quick_field_rows", - "contract_description_part", - "contract_tags", - "description_mapping", - "field_by_name", - "field_declared_lifecycle_status", - "field_examples_text", - "field_lifecycle_status", - - "is_blank_quality_row", - "is_blank_quick_field_row", - "normalize_tags", - "optional_int", - "quality_rows", - "rule_condition", - "schema_items", - "selected_schema_field_names", - "set_contract_description_part", - "set_contract_tags_list", - "set_field_examples", - "set_field_lifecycle_status", - "set_mapping_text", - "tags_to_text", - "text_to_tags", -] From 4b7847588651ba04642296b9ed59d9403f76f1cb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:08:49 +1000 Subject: [PATCH 429/616] docs(lifecycle): document only canonical lifecycle semantics --- docs/lifecycle_semantics.md | 192 ++++++++++++++---------------------- 1 file changed, 72 insertions(+), 120 deletions(-) diff --git a/docs/lifecycle_semantics.md b/docs/lifecycle_semantics.md index 204a38c1..5748b9d1 100644 --- a/docs/lifecycle_semantics.md +++ b/docs/lifecycle_semantics.md @@ -1,156 +1,108 @@ # SemaPact Lifecycle Governance Semantics -This document describes the authoritative lifecycle model, resolution order, and governance scope rules for Open Data Contracts (ODCS) within SemaPact. +This document defines the canonical lifecycle model and governance scope rules for Open Data Contract Standard (ODCS) contracts. ---- +## Supported lifecycle states -## 1. Supported Lifecycle States +SemaPact recognizes exactly four lifecycle states: -SemaPact governance operates on four canonical lifecycle states: - -| State | Governance Meaning | Breaking Checks | Auto-Deprecation | Mutability | +| State | Governance meaning | Breaking checks | Auto-deprecation | Mutability | |---|---|---|---|---| -| `draft` | Development / non-production. | ❌ Skipped | ❌ Skipped | ✅ Free evolution | -| `active` | Production contract. Strict governance applies. | ✅ Enforced | ✅ Applied | ⚠ Governed | -| `deprecated` | Marked for decommissioning. | ❌ Skipped | ❌ Skipped | ⚠ Metadata only | -| `retired` | End of life / decommissioned. | ❌ Skipped | ❌ Skipped | ❌ Immutable (BLOCK) | - -### Read Normalization & Aliases - -Lifecycle status strings are normalized case-insensitively with leading/trailing whitespace removed: -- `"draft"` -> `LifecycleStatus.DRAFT` -- `"proposed"` -> `LifecycleStatus.DRAFT` - > [!NOTE] - > `"proposed"` is a read-only governance interpretation alias for ODCS compliance; SemaPact does not rewrite ODCS `status: proposed` to `status: draft` in the contract YAML merely during resolution. -- `"active"` -> `LifecycleStatus.ACTIVE` -- `"deprecated"` -> `LifecycleStatus.DEPRECATED` -- `"retired"` -> `LifecycleStatus.RETIRED` - -Any unknown or unsupported lifecycle status value is rejected by normalization and flagged by `ContractValidator` as a validation issue. +| `draft` | Development / non-production | skipped | skipped | free evolution | +| `active` | Production contract | enforced | applied | governed | +| `deprecated` | Marked for decommissioning | skipped | skipped | metadata only | +| `retired` | End of life | skipped | skipped | immutable | ---- +Lifecycle strings are normalized case-insensitively with surrounding whitespace removed. Any value other than `draft`, `active`, `deprecated`, or `retired` is invalid. -## 2. Canonical Authority & Fallback Order +## Canonical lifecycle authority -Authority is defined by entity level: +Lifecycle authority depends on entity level. -### Contract Root -1. Native `contract.status` (canonical ODCS root status field) -2. `contract.customProperties.lifecycleStatus` (legacy fallback) -3. Default: `LifecycleStatus.DRAFT` +For the contract root, only the native ODCS `contract.status` field is authoritative. When it is absent, the resolver returns `DRAFT`. An explicitly invalid value is reported by `ContractValidator` and governance fails closed. -### SchemaObject & SchemaProperty (including nested `properties[]` and `items`) -1. Entity's own `customProperties.lifecycleStatus` only (canonical ODCS extension point) -2. Note: Non-standard `schema.status` or `property.status` attributes are not governance authorities. +For `SchemaObject` and `SchemaProperty` entities, including nested properties/items, SemaPact uses the entity's `customProperties.lifecycleStatus` annotation. Non-standard `schema.status` or `property.status` attributes are not governance authorities. ---- +There is no second contract-root lifecycle representation. -## 3. Declared vs. Effective Lifecycle +## Declared and effective lifecycle -SemaPact strictly differentiates between **declared lifecycle** and **effective lifecycle**: +Declared lifecycle is the status explicitly attached to one schema/property entity through `customProperties.lifecycleStatus`. -### Declared Lifecycle -- The status explicitly annotated on an individual entity (`customProperties.lifecycleStatus`). -- Resolved via `resolve_declared_entity_lifecycle(entity)`. -- Used for release change classification to determine if an entity was newly marked deprecated. +Effective lifecycle includes parent governance scope: -### Effective Governance Lifecycle -- The status of an entity taking into account parent governance scope and hierarchy. -- Resolved via: - - `resolve_contract_lifecycle(contract)` - - `resolve_schema_lifecycle(schema_obj, contract=contract)` - - `resolve_property_lifecycle(prop, parent_lifecycle=...)` +```text +contract.status + ↓ +effective contract lifecycle + ↓ +schema declared lifecycle, when contract is ACTIVE + ↓ +property declared lifecycle, when parent is ACTIVE +``` -### Recursive Inheritance Invariant -> [!IMPORTANT] -> **Inactive Ancestor Invariant**: An inactive ancestor (`draft`, `deprecated`, `retired`) places its entire subtree outside active governance scope. A child entity cannot reactivate itself past an inactive parent. +The inactive-ancestor invariant is strict: a child cannot reactivate itself past a `draft`, `deprecated`, or `retired` ancestor. -#### Resolution Hierarchy: -1. **Contract**: `contract.status` -> `contract.customProperties.lifecycleStatus` -> `DRAFT`. -2. **Schema**: - - If `effective contract != ACTIVE` -> parent contract effective status wins. - - Else if schema has declared `lifecycleStatus` -> schema declared status. - - Else -> `ACTIVE`. -3. **Property (Top-level or Nested `properties[]` / `items`)**: - - If `parent_lifecycle != ACTIVE` -> `parent_lifecycle` wins. - - Else if property has declared `lifecycleStatus` -> property declared status. - - Else -> `ACTIVE`. +Resolution rules are therefore: ---- +1. Contract: native `contract.status`, otherwise `DRAFT`. +2. Schema: if the contract is not `ACTIVE`, inherit the contract lifecycle; otherwise use the schema declaration when present, else `ACTIVE`. +3. Property: if its parent is not `ACTIVE`, inherit the parent lifecycle; otherwise use the property declaration when present, else `ACTIVE`. -## 4. Governance Participation Matrix +## Governance participation matrix -| Contract Status | Schema Declared | Property Declared | Effective Schema | Effective Property | Breaking Checks Scope | +| Contract status | Schema declared | Property declared | Effective schema | Effective property | Breaking checks | |---|---|---|---|---|---| -| `active` | *(none)* | *(none)* | `active` | `active` | ✅ Included | -| `active` | `draft` | `active` | `draft` | `draft` | ❌ Excluded (parent inactive) | -| `active` | `deprecated` | `active` | `deprecated` | `deprecated` | ❌ Excluded (parent inactive) | -| `active` | `active` | `draft` | `active` | `draft` | ❌ Excluded (property draft) | -| `active` | `active` | `deprecated` | `active` | `deprecated` | ❌ Excluded (property deprecated) | -| `draft` | `active` | `active` | `draft` | `draft` | ❌ Excluded (contract draft) | -| `retired` | `active` | `active` | `retired` | `retired` | ❌ Excluded (contract retired) | - ---- +| `active` | none | none | `active` | `active` | included | +| `active` | `draft` | `active` | `draft` | `draft` | excluded | +| `active` | `deprecated` | `active` | `deprecated` | `deprecated` | excluded | +| `active` | `active` | `draft` | `active` | `draft` | excluded | +| `active` | `active` | `deprecated` | `active` | `deprecated` | excluded | +| `draft` | `active` | `active` | `draft` | `draft` | excluded | +| `deprecated` | `active` | `active` | `deprecated` | `deprecated` | excluded | +| `retired` | `active` | `active` | `retired` | `retired` | excluded | -## 5. Fail-Closed Validation Integration +## Fail-closed validation -> [!NOTE] -> **Total Resolvers vs. Validation Authority**: -> Lifecycle resolvers are intentionally total for deterministic analysis; lifecycle validity is enforced by `ContractValidator`. -> -> - `normalize_status()`: Strict parser (`ValueError` on unsupported values). -> - Resolvers (`resolve_contract_lifecycle`, `resolve_schema_lifecycle`, `resolve_property_lifecycle`): Total / tolerant functions with safe fallbacks (`DRAFT` / `None`), preventing crashes in downstream analysis pipelines. -> - `ContractValidator`: Authoritative validity check emitting `ValidationIssue(severity="error")` on invalid lifecycle values. -> - `evaluate_governance_decision()`: Blocks changes with `VALIDATION_FAILED` whenever `ContractValidator` reports issues. +Lifecycle resolvers are total so deterministic analysis can continue, while `ContractValidator` is the validity authority. -When an unknown or malformed lifecycle status is provided (e.g. `status: "unknown"`): -1. `ContractValidator` detects invalid status and records a `VALIDATION_FAILED` issue. -2. `evaluate_governance_decision()` receives `ValidationOutcome(valid=False)` and emits a deterministic `GovernanceDecision(decision=DecisionResult.BLOCK)`. -3. Evaluation and merge pipelines execute deterministically without leaking unhandled exceptions. +- `normalize_status()` raises on unsupported values. +- `resolve_contract_lifecycle()` returns `DRAFT` when root status is absent or invalid. +- schema/property declared-status resolution returns `None` when no valid declaration exists. +- `ContractValidator` reports malformed lifecycle values. +- `evaluate_governance_decision()` turns validation failure into `DecisionResult.BLOCK`. ---- +This separation prevents malformed input from crashing analysis without treating it as valid governed state. -## 6. Retired Contract Immutability +## Retired contract immutability -`RETIRED` represents a terminal, end-of-life governed state. A governed contract whose effective lifecycle is `retired` is permanently frozen and immutable. +`RETIRED` is terminal. -### Invariant: ```text -base effective lifecycle == RETIRED +base lifecycle == RETIRED AND base contract != candidate contract - ↓ + ↓ RETIRED_CONTRACT_MODIFIED - ↓ + ↓ DecisionResult.BLOCK ``` -Any semantic mutation against a retired base contract must be authoritatively blocked by `evaluate_governance_decision()` with `GovernanceReasonCode.RETIRED_CONTRACT_MODIFIED`. This applies to all differences, including: -- Technical schema changes (adding, removing, or modifying schemas/properties) -- Business metadata changes (descriptions, tags, business names) -- Schema and property lifecycle modifications -- Quality rules and relationship changes -- Root status changes (reactivation) -- Version changes -- Purely descriptive changes - -### Operation Gating Matrix: - -| Operation Category | Operation / Entrypoint | Gate Evaluated | Decision / Result on Retired Contract | -|---|---|---|---| -| Read-only / Inspect | `ContractLoader.load()` | *(none)* | ✅ Allowed (loading/inspecting preserved) | -| Analysis / Planning | `semapact plan`, `GovernanceService.evaluate()` | `ANALYZE` | ✅ Allowed (returns/displays `BLOCK` decision) | -| Release Classification | `semapact release classify` | `ANALYZE` | ✅ Allowed (returns classification with `BLOCK`) | -| Export Artifacts | `semapact export`, `export_ge` | *(none)* | ✅ Allowed (derives downstream views) | -| Technical Merge Output | `semapact merge` | `PROPOSE` | ❌ Blocked (`GovernanceBlockedError`) | -| Ingestion into Governed Contract | `semapact import --existing` | `PROPOSE` | ❌ Blocked (`GovernanceBlockedError`) | -| Semantic Lifecycle Edit | `semapact lifecycle promote / deprecate` | `APPLY` | ❌ Blocked (`GovernanceBlockedError`) | -| Release Preparation | `semapact release prepare` | `PROPOSE` | ❌ Blocked (`GovernanceBlockedError`) | -| Release PR Creation | `semapact release create-pr` | `PROPOSE` | ❌ Blocked (`GovernanceBlockedError`) | -| Batch Release Manifest | `semapact release build-manifest` | `PROPOSE` | ❌ Skipped from manifest tasks | -| Automation Pipeline | `ContractPipeline.run()` | `CI` | ❌ Blocked (`GovernanceBlockedError`) | - -### Lifecycle Transitions & Reactivations: -- **Transition into Retired**: `ACTIVE / DEPRECATED / DRAFT -> RETIRED` produces `DecisionResult.REVIEW` with `GovernanceReasonCode.CONTRACT_RETIRED_TRANSITION`. This is a valid lifecycle transition requiring review, not a mutation of an already-retired contract. -- **Reactivation**: `RETIRED -> ACTIVE / DRAFT / DEPRECATED` produces `DecisionResult.BLOCK` with `GovernanceReasonCode.RETIRED_CONTRACT_MODIFIED`. No unretire/reactivation semantics exist. -- **Unchanged Retired Contracts**: `retired base == retired candidate` produces `DecisionResult.ALLOW` (when no other violation exists), preserving history, export, and verification workflows. +This applies to technical schema, business metadata, lifecycle annotations, quality rules, relationships, root status, version, and descriptive changes. + +Transitioning an existing non-retired contract into `retired` is a reviewable transition. Reactivating a retired contract is blocked. An unchanged retired contract remains readable and analyzable. + +## Operation boundaries + +Read-only loading, analysis, classification, and export remain available when a decision is `BLOCK`; they report the decision rather than mutating state. + +Mutation-capable paths consume that decision at their canonical boundary: + +| Boundary | Canonical entry point | Authority | +|---|---|---| +| governed merge/import | merge/import application workflow | governance PROPOSE gate | +| formal release | `semapact release assess → approve (REVIEW only) → finalize` | PUBLISH approval + exact ReleaseBundle | +| candidate deployment | `semapact deployment assess → deploy` | governance provenance + protected runtime execution context | +| finalized release deployment | `ContractRelease → deployment assess → deploy` | exact release provenance + protected runtime execution context | + +A `ContractRelease` never grants runtime execution permission. From ac26df14f0c14c7f1f9d3842c1071395e1b93e41 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:08:53 +1000 Subject: [PATCH 430/616] docs(governance): remove obsolete manifest boundary --- docs/governance_analysis_boundary.md | 93 ++++++++++------------------ 1 file changed, 34 insertions(+), 59 deletions(-) diff --git a/docs/governance_analysis_boundary.md b/docs/governance_analysis_boundary.md index 7bbb0a8b..9b698503 100644 --- a/docs/governance_analysis_boundary.md +++ b/docs/governance_analysis_boundary.md @@ -2,24 +2,21 @@ SemaPact governance analysis is a pure, repeatable operation. It determines what a proposed contract change means; it does not perform the change or publish anything. -## Canonical Boundary +## Canonical boundary ```text -Analyze - base + candidate + ChangeContext +base + candidate + ChangeContext ↓ - GovernanceDecision - -Apply - explicit local/candidate mutation - -Publish / Deploy / CI - explicit protected side effects +GovernanceDecision + ↓ +application workflow + ↓ +explicit mutation / release / deployment boundary ``` -Governance analysis remains upstream of all mutation-capable workflows. Later phases consume its result rather than recomputing governance semantics. +Later phases consume the decision rather than recomputing governance semantics. -## Analysis Entry Points +## Analysis entry points The authoritative domain evaluator is: @@ -33,85 +30,63 @@ evaluate_governance_decision( ) ``` -Application clients should normally enter governance analysis through: +Application clients normally enter through: ```python GovernanceService.evaluate(...) ``` -`GovernanceService.merge_and_evaluate(...)` may construct an in-memory merged candidate before evaluation, but it must not persist that candidate or perform external publication side effects. +`GovernanceService.merge_and_evaluate(...)` may construct an in-memory merged candidate before evaluation, but it must not persist that candidate or perform publication/runtime side effects. -## Analysis Invariants +## Analysis invariants Governance analysis must not: -- mutate the base contract; -- mutate the candidate contract; -- write contract, manifest, or other files; -- create or modify Git branches, commits, or tags; +- mutate the base or candidate contract; +- write contract or artifact files; +- create Git branches, commits, or tags; - create pull requests; -- publish metadata or deployment artifacts; +- publish metadata; - apply a release version; -- invoke deployment or runtime mutation. +- invoke runtime mutation. -For the same normalized inputs and the same `ChangeContext`, repeated analysis must produce the same deterministic `GovernanceDecision`, including the same `decision_id`. +For identical normalized inputs and `ChangeContext`, repeated analysis must produce the same deterministic `GovernanceDecision`, including `decision_id`. -Analysis may compute evidence such as validation results, canonical `GovernanceChange` values, policy findings, breaking status, and the minimum required version bump. Computing a required version bump is analysis; applying a version is not. +Computing validation evidence, canonical `GovernanceChange` values, policy findings, breaking status, and minimum required version bump is analysis. Applying a version or changing runtime state is not. -## Policy Findings, Decisions, and Gate Results +## Policy findings, decisions, and execution authority -These three concepts have different meanings and must not be treated as interchangeable: +These are distinct: ```text policy.valid -= no policy-breaking findings were detected += whether policy-breaking findings were detected -decision +GovernanceDecision = authoritative governance disposition -gate result -= authoritative permission for a specific operation +execution authority += permission to cross a specific side-effect boundary ``` -A breaking change can therefore legitimately produce: +A breaking change may legitimately produce: ```text policy.valid = false decision = REVIEW ``` -This means the lifecycle policy found breaking evidence, not that every operation is prohibited. For example, analysis remains readable while a CI, publish, or deploy operation may require review before side effects are allowed. - -External consumers must use `GovernanceDecision` and the operation-specific governance gate for authorization. They must not use `policy.valid`, `breaking`, or individual reason codes as independent permission checks. - -## Side-Effecting Operations - -Mutation-capable workflows are downstream consumers of governance analysis. They must consume an already-produced `GovernanceDecision` (or an application result containing that decision) and enforce the appropriate `GovernanceOperation` gate before performing protected side effects. - -Conceptually: - -```text -GovernanceDecision - ↓ -GovernanceOperation gate - ↓ -explicit APPLY / PUBLISH / DEPLOY / CI boundary -``` +Consumers must use the authoritative `GovernanceDecision` and the workflow boundary that owns the side effect. They must not reinterpret `policy.valid`, `breaking`, or individual reason codes as independent permission checks. -A client or adapter must not independently reinterpret breaking changes, validation, lifecycle policy, or version requirements to bypass the authoritative decision. +Formal release REVIEW approval is scoped to the exact release artifact. Runtime deployment permission belongs to the protected CI/CD execution context; it is not inferred from `ContractRelease`. -## Regression Protection +## Regression protection -`tests/test_governance_analysis_boundary.py` protects this architecture contract by checking that: +`tests/test_governance_analysis_boundary.py` protects analysis purity and determinism. -- the authoritative evaluator does not import integration or mutation layers; -- decision evaluation performs no filesystem or process side effects; -- input contracts remain unchanged; -- repeated evaluation is deterministic; -- the `GovernanceService.evaluate(...)` application boundary preserves the same purity guarantees. +The two canonical GitHub Actions examples are architecture fitness tests for the public workflow boundary: -`tests/test_pipeline_manifest.py` additionally protects the CI boundary by checking that: +- `examples/github/data-product-ci-cd.yml` +- `examples/github/central-contract-repo-ci-cd.yml` -- legacy manifest breaking-change fields are projections of the authoritative decision rather than a parallel reason-code interpretation; -- reviewable non-breaking changes do not invent breaking evidence; -- the automation pipeline enforces the CI gate once at the artifact side-effect boundary. +They must use public CLI surfaces only. If they require internal artifact reconstruction, duplicate approval logic, unnecessary Git round-trips, or internal implementation IDs, that is treated as an application/CLI boundary defect. From b9c08d5ff7599d4dfbd6e3c7029b69fa0b27c947 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:09:32 +1000 Subject: [PATCH 431/616] refactor(api): remove obsolete release workflow exports --- semapact/__init__.py | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/semapact/__init__.py b/semapact/__init__.py index c6a5d3a8..31af619e 100644 --- a/semapact/__init__.py +++ b/semapact/__init__.py @@ -20,47 +20,6 @@ "ContractValidator": ("semapact.core.validator", "ContractValidator"), "AzureDevOpsConfig": ("semapact.devops.pr_creator", "AzureDevOpsConfig"), "PullRequestCreator": ("semapact.devops.pr_creator", "PullRequestCreator"), - "BatchReleaseManifestBuild": ( - "semapact.devops.release_workflow", - "BatchReleaseManifestBuild", - ), - "BatchReleaseTask": ("semapact.devops.release_workflow", "BatchReleaseTask"), - "ReleasePullRequestPlan": ( - "semapact.devops.release_workflow", - "ReleasePullRequestPlan", - ), - "RepositoryContractChange": ( - "semapact.devops.release_workflow", - "RepositoryContractChange", - ), - "batch_manifest_build_to_dict": ( - "semapact.devops.release_workflow", - "batch_manifest_build_to_dict", - ), - "build_batch_release_manifest": ( - "semapact.devops.release_workflow", - "build_batch_release_manifest", - ), - "build_release_pr_plan": ( - "semapact.devops.release_workflow", - "build_release_pr_plan", - ), - "create_release_pull_request": ( - "semapact.devops.release_workflow", - "create_release_pull_request", - ), - "create_release_pull_requests_from_manifest": ( - "semapact.devops.release_workflow", - "create_release_pull_requests_from_manifest", - ), - "load_batch_release_tasks": ( - "semapact.devops.release_workflow", - "load_batch_release_tasks", - ), - "repository_change_to_dict": ( - "semapact.devops.release_workflow", - "repository_change_to_dict", - ), "SparkSqlContractExporter": ( "semapact.exporters.sql_exporter", "SparkSqlContractExporter", From 429feb37b325df742ca2a844be83d37dd13a1a2f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:10:23 +1000 Subject: [PATCH 432/616] refactor(core): remove deleted release compatibility exports --- semapact/core/__init__.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/semapact/core/__init__.py b/semapact/core/__init__.py index a92a6658..08ab9f06 100644 --- a/semapact/core/__init__.py +++ b/semapact/core/__init__.py @@ -6,16 +6,6 @@ set_contract_tags_list, ) from semapact.core.loader import ContractLoader, load_contract -from semapact.core.release import ( - ContractChangeAssessment, - PromotionResult, - apply_release_candidate, - classify_contract_change, - classify_version_bump, - parse_release_tag_version, - prepare_release_candidate, - suggest_release_version, -) from semapact.core.validator import ( ContractValidator, ValidationIssue, @@ -23,22 +13,14 @@ ) __all__ = [ - "ContractChangeAssessment", "ContractLoader", "ContractValidator", - "PromotionResult", - "apply_release_candidate", - "classify_contract_change", - "classify_version_bump", "ValidationIssue", "ValidationReport", "load_contract", "normalize_draft_contract", "normalize_tags", - "parse_release_tag_version", - "prepare_release_candidate", "schema_items", "set_contract_description_part", "set_contract_tags_list", - "suggest_release_version", ] From 513768e479576352eccfde90ea16798019940f98 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:15:23 +1000 Subject: [PATCH 433/616] refactor(governance): import version type from canonical owner --- semapact/governance/public.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/governance/public.py b/semapact/governance/public.py index ca9205a2..13e7fbf3 100644 --- a/semapact/governance/public.py +++ b/semapact/governance/public.py @@ -6,7 +6,7 @@ from typing import Any, Literal from pydantic import AliasChoices, BaseModel, ConfigDict, Field, JsonValue -from semapact.core.release import RequiredBump +from semapact.versioning import RequiredBump from semapact.governance.models import ( DecisionResult, GovernanceDecision, From c78abe557c250459ffe48a3f3e7a7215698cfd22 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:15:28 +1000 Subject: [PATCH 434/616] test(governance): use canonical change classification owner --- tests/test_governance_change_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_governance_change_integration.py b/tests/test_governance_change_integration.py index dc636150..2bda4fc9 100644 --- a/tests/test_governance_change_integration.py +++ b/tests/test_governance_change_integration.py @@ -18,7 +18,7 @@ ) from semapact.change_context import ChangeContext -from semapact.core.release import classify_contract_change +from semapact.lifecycle.change_classification import classify_contract_change from semapact.governance import DecisionResult, evaluate_governance_decision from semapact.governance_codes import GovernanceReasonCode from semapact.lifecycle.changes import ( From df451aabe060973dbbaa9b965b2d99a320d49473 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:15:31 +1000 Subject: [PATCH 435/616] test(governance): use canonical version type owner --- tests/test_governance_golden_scenarios.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_governance_golden_scenarios.py b/tests/test_governance_golden_scenarios.py index 56c22e04..77a4ef55 100644 --- a/tests/test_governance_golden_scenarios.py +++ b/tests/test_governance_golden_scenarios.py @@ -16,7 +16,7 @@ from open_data_contract_standard.model import OpenDataContractStandard from semapact.change_context import ChangeContext -from semapact.core.release import RequiredBump +from semapact.versioning import RequiredBump from semapact.governance import ( DecisionResult, evaluate_governance_decision, From dabaf1a0586c17e86d7cf9fb23dba44ef4ad2dca Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:15:34 +1000 Subject: [PATCH 436/616] test(identity): use canonical change classification owner --- tests/test_identity_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_identity_integration.py b/tests/test_identity_integration.py index 238c4856..be186ec4 100644 --- a/tests/test_identity_integration.py +++ b/tests/test_identity_integration.py @@ -3,7 +3,7 @@ import pytest from open_data_contract_standard.model import SchemaObject, SchemaProperty -from semapact.core.release import classify_contract_change +from semapact.lifecycle.change_classification import classify_contract_change from semapact.exceptions import ValidationError from semapact.governance import ChangeContext from semapact.lifecycle.merge_engine import ContractMergeEngine From 73de7aee4410d328ce29aa27c89fa336cb28e7cc Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:15:37 +1000 Subject: [PATCH 437/616] test(governance): use canonical application service --- tests/test_governance_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_governance_service.py b/tests/test_governance_service.py index b45a4208..7638ad04 100644 --- a/tests/test_governance_service.py +++ b/tests/test_governance_service.py @@ -12,7 +12,7 @@ SchemaProperty, ) -from semapact.services import GovernanceService +from semapact.application.services.governance import GovernanceService def _cp(key: str, value: str) -> CustomProperty: From 05bff54673930e44770f23a4cec7e0724df4dacd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:15:46 +1000 Subject: [PATCH 438/616] test(governance): remove services compatibility dependency --- tests/test_governance_analysis_boundary.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_governance_analysis_boundary.py b/tests/test_governance_analysis_boundary.py index 82ebf0f2..f0b81990 100644 --- a/tests/test_governance_analysis_boundary.py +++ b/tests/test_governance_analysis_boundary.py @@ -22,7 +22,7 @@ import semapact.governance.evaluator as evaluator_module from semapact.change_context import ChangeContext from semapact.governance.evaluator import evaluate_governance_decision -from semapact.services import GovernanceService +from semapact.application.services.governance import GovernanceService TEST_CONTEXT = ChangeContext(effective_date=date(2026, 1, 1)) @@ -33,7 +33,6 @@ "semapact.importers", "semapact.interfaces", "semapact.orchestrator", - "semapact.services", ) _FORBIDDEN_SIDE_EFFECT_IMPORT_ROOTS = { "git", From e7a78784fad37426f5d1bf94edd704ffc9eb5c9b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:15:49 +1000 Subject: [PATCH 439/616] test(reconciliation): use canonical application service --- tests/test_reconciliation_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_reconciliation_service.py b/tests/test_reconciliation_service.py index 6d5f937f..93456660 100644 --- a/tests/test_reconciliation_service.py +++ b/tests/test_reconciliation_service.py @@ -16,7 +16,7 @@ with_observed_state_fingerprint, ) from semapact.reconciliation import RuntimeDriftStatus -from semapact.services.reconciliation_service import ReconciliationService +from semapact.application.services.reconciliation import ReconciliationService class _Loader: From 5ea2902c646d81b845e11a1ea2d3083352c8fb41 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:15:55 +1000 Subject: [PATCH 440/616] test(release): use canonical application services --- tests/test_release_planning_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_release_planning_service.py b/tests/test_release_planning_service.py index 0211bcdb..d8d59282 100644 --- a/tests/test_release_planning_service.py +++ b/tests/test_release_planning_service.py @@ -7,7 +7,8 @@ ) from semapact.contractops import VersionAuthorityConfig, resolve_release_version -from semapact.services import GovernanceService, ReleasePlanningService +from semapact.application.services.governance import GovernanceService +from semapact.application.services.release_planning import ReleasePlanningService class CountingGovernanceService: From 3c3102fe450135f13859deb146e999816faecb42 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:15:58 +1000 Subject: [PATCH 441/616] test(versioning): use canonical application service --- tests/test_version_authority_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_version_authority_service.py b/tests/test_version_authority_service.py index 793f3f8c..29a39999 100644 --- a/tests/test_version_authority_service.py +++ b/tests/test_version_authority_service.py @@ -6,7 +6,7 @@ from semapact.contractops.integrity import compute_release_plan_id from semapact.core.config import ConfigManager from semapact.exceptions import ReleaseValidationError, ValidationError -from semapact.services import VersionAuthorityService +from semapact.application.services.version_authority import VersionAuthorityService from semapact.versioning import RequiredBump From 49871dd1ade88db527ef6e0b4b30f270f4fc7cb5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:16:07 +1000 Subject: [PATCH 442/616] test(lifecycle): remove obsolete release compatibility scenarios --- tests/test_retired_immutability.py | 102 +---------------------------- 1 file changed, 3 insertions(+), 99 deletions(-) diff --git a/tests/test_retired_immutability.py b/tests/test_retired_immutability.py index a2fcdb21..27c8997e 100644 --- a/tests/test_retired_immutability.py +++ b/tests/test_retired_immutability.py @@ -4,7 +4,7 @@ import json from datetime import date from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from open_data_contract_standard.model import ( @@ -19,7 +19,6 @@ from semapact.change_context import ChangeContext from semapact.core.loader import ContractLoader -from semapact.devops.release_workflow import build_batch_release_manifest, create_release_pull_request from semapact.exceptions import GovernanceBlockedError from semapact.governance import ( DecisionResult, @@ -35,12 +34,9 @@ run_lifecycle_promote, ) from semapact.interfaces.commands.merge_cmd import run_merge -from semapact.interfaces.commands.release_cmd import ( - run_release_classify, - run_release_prepare, -) +from semapact.interfaces.commands.release_cmd import run_release_classify from semapact.orchestrator.pipeline import ContractPipeline -from semapact.services import GovernanceService +from semapact.application.services.governance import GovernanceService from semapact.utils.schema_utils import contract_to_dict from semapact.utils.yaml_utils import dump_yaml @@ -492,72 +488,6 @@ def test_lifecycle_deprecate_property_blocks_retired_contract(self, tmp_path: Pa ) assert contract_path.read_text(encoding="utf-8") == original_content - def test_release_prepare_blocks_retired_contract(self, tmp_path: Path) -> None: - """run_release_prepare blocks retired contract modification and writes no output.""" - base_retired = _make_retired_contract() - candidate = _make_retired_contract() - candidate.description = Description(usage="Candidate with metadata updates") - - base_path = dump_yaml(contract_to_dict(base_retired), tmp_path / "base.yaml") - cand_path = dump_yaml(contract_to_dict(candidate), tmp_path / "cand.yaml") - out_path = tmp_path / "promoted.yaml" - - args = argparse.Namespace( - base=str(base_path), - candidate=str(cand_path), - output=str(out_path), - release_tag="orders/v1.1.0", - runtime_context="auto", - effective_date="2026-08-14", - ) - - with pytest.raises(GovernanceBlockedError) as exc_info: - run_release_prepare(args) - - assert exc_info.value.operation == GovernanceOperation.PROPOSE - assert exc_info.value.decision is not None - assert any( - r.code == GovernanceReasonCode.RETIRED_CONTRACT_MODIFIED - for r in exc_info.value.decision.reasons - ) - assert not out_path.exists() - - def test_release_create_pr_blocks_retired_contract(self, tmp_path: Path) -> None: - """create_release_pull_request blocks retired contract mutation before Git/file mutations.""" - base_retired = _make_retired_contract() - candidate = _make_retired_contract() - candidate.description = Description(usage="Modified retired contract") - - repo_dir = tmp_path / "repo" - repo_dir.mkdir() - contract_file = repo_dir / "contracts" / "orders.yaml" - contract_file.parent.mkdir(parents=True) - dump_yaml(contract_to_dict(base_retired), contract_file) - - mock_config = MagicMock() - - with pytest.raises(GovernanceBlockedError) as exc_info: - create_release_pull_request( - config=mock_config, - repo_path=str(repo_dir), - contract_repo_path="contracts/orders.yaml", - base_contract=base_retired, - candidate_contract=candidate, - release_tag="orders/v1.1.0", - source_branch="release/orders-v1.1.0", - target_branch="main", - context=TEST_CONTEXT, - ) - - assert exc_info.value.operation == GovernanceOperation.PROPOSE - assert exc_info.value.decision is not None - assert any( - r.code == GovernanceReasonCode.RETIRED_CONTRACT_MODIFIED - for r in exc_info.value.decision.reasons - ) - # Verify no git push or PR creation was called - mock_config.assert_not_called() - def test_pipeline_ci_run_blocks_retired_contract(self, tmp_path: Path) -> None: """ContractPipeline.run on retired base writes audit manifest and blocks before writing artifacts.""" base_retired = _make_retired_contract() @@ -609,32 +539,6 @@ def test_pipeline_ci_run_blocks_retired_contract(self, tmp_path: Path) -> None: assert not merged_out.exists() assert not suite_out.exists() - def test_batch_release_manifest_skips_retired_contract(self, tmp_path: Path) -> None: - """build_batch_release_manifest skips retired mutated contracts via PROPOSE gate.""" - base_root = tmp_path / "base" - cand_root = tmp_path / "cand" - base_root.mkdir() - cand_root.mkdir() - - base_retired = _make_retired_contract() - cand_retired_mutated = _make_retired_contract() - cand_retired_mutated.description = Description(usage="Mutated retired description") - - dump_yaml(contract_to_dict(base_retired), base_root / "orders.yaml") - dump_yaml(contract_to_dict(cand_retired_mutated), cand_root / "orders.yaml") - - build = build_batch_release_manifest( - base_root=base_root, - candidate_root=cand_root, - context=TEST_CONTEXT, - ) - - # The retired mutated contract must be skipped, not included in tasks - assert len(build.tasks) == 0 - assert len(build.skipped) == 1 - assert build.skipped[0].contract_repo_path == "orders.yaml" - - # ============================================================================== # 3. Read-Only Matrix # ============================================================================== From e551f2dc0ee20e76884cace33fecb1a92721c539 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:17:17 +1000 Subject: [PATCH 443/616] refactor(deployment): start canonical plan schema at v1 --- semapact/deployment/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index f6cd0f17..9689cafd 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -131,7 +131,7 @@ class DeploymentPlan(DeploymentModel): contract_version: str target: DeploymentTarget actions: tuple[DeploymentAction, ...] - plan_version: Literal["5"] = "5" + plan_version: Literal["1"] = "1" @field_validator( "deployment_plan_id", @@ -226,9 +226,9 @@ def compute_deployment_plan_id( contract_version: str, target: DeploymentTarget, actions: Sequence[DeploymentAction], - plan_version: str = "5", + plan_version: str = "1", ) -> str: - if plan_version != "5": + if plan_version != "1": raise ValueError(f"Unsupported canonical DeploymentPlan version: {plan_version}") return deterministic_uuid5( SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, From 056bbaab2df61b98bcd4e2389f9ecdcb762f128c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:17:20 +1000 Subject: [PATCH 444/616] refactor(deployment): start deployment bundle schema at v1 --- semapact/application/models/deployment_workflow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py index 49fc38d3..bad50a28 100644 --- a/semapact/application/models/deployment_workflow.py +++ b/semapact/application/models/deployment_workflow.py @@ -48,7 +48,7 @@ class DeploymentBundle(BaseModel): decision: GovernanceDecision | None = None change_set: ChangeSet | None = None contract_release: ContractRelease | None = None - bundle_version: Literal["4"] = "4" + bundle_version: Literal["1"] = "1" @property def is_release(self) -> bool: @@ -171,7 +171,7 @@ def compute_deployment_bundle_digest( decision: GovernanceDecision | None = None, change_set: ChangeSet | None = None, contract_release: ContractRelease | None = None, - bundle_version: str = "4", + bundle_version: str = "1", ) -> str: """Return the immutable digest for one target-specific deployment bundle.""" payload = { From ed74716bc928241aae7ccb1b402f8b990b08bb70 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:17:23 +1000 Subject: [PATCH 445/616] refactor(history): start operational event schema at v1 --- semapact/history/operational.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/semapact/history/operational.py b/semapact/history/operational.py index 46162f63..16082648 100644 --- a/semapact/history/operational.py +++ b/semapact/history/operational.py @@ -28,7 +28,7 @@ class OperationalDeploymentEvent(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") event_id: str - event_version: Literal["2"] = "2" + event_version: Literal["1"] = "1" bundle_digest: str contract_release_id: str | None = None contract_id: str @@ -150,7 +150,7 @@ def build_operational_deployment_event( ) -> OperationalDeploymentEvent: normalized_platform = platform.strip().casefold() event_id = compute_operational_deployment_event_id( - event_version="2", + event_version="1", bundle_digest=bundle_digest, contract_release_id=contract_release_id, contract_id=contract_id, @@ -171,7 +171,7 @@ def build_operational_deployment_event( ) return OperationalDeploymentEvent( event_id=event_id, - event_version="2", + event_version="1", bundle_digest=bundle_digest, contract_release_id=contract_release_id, contract_id=contract_id, From fa98a02e2418635f7a036a3689b19797d76a20df Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:17:26 +1000 Subject: [PATCH 446/616] test(deployment): assert greenfield v1 plan schema --- tests/test_deployment_plan.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_deployment_plan.py b/tests/test_deployment_plan.py index 94d744ea..46632d18 100644 --- a/tests/test_deployment_plan.py +++ b/tests/test_deployment_plan.py @@ -91,7 +91,7 @@ def _target() -> DeploymentTarget: ) -def test_candidate_source_produces_canonical_v5_plan() -> None: +def test_candidate_source_produces_canonical_v1_plan() -> None: source = build_candidate_deployment_source( _contract(), revision_ref="rev:candidate", @@ -99,7 +99,7 @@ def test_candidate_source_produces_canonical_v5_plan() -> None: plan = build_deployment_plan_from_source(source, _target()) - assert plan.plan_version == "5" + assert plan.plan_version == "1" assert plan.source_snapshot_id == source.source_snapshot_id assert plan.contract_id == source.contract_id assert plan.contract_version == source.contract_version @@ -107,13 +107,13 @@ def test_candidate_source_produces_canonical_v5_plan() -> None: assert not hasattr(plan, "release_plan_id") -def test_finalized_release_source_produces_canonical_v5_plan() -> None: +def test_finalized_release_source_produces_canonical_v1_plan() -> None: release = _release() source = build_contract_release_deployment_source(release) plan = build_deployment_plan_from_source(source, _target()) - assert plan.plan_version == "5" + assert plan.plan_version == "1" assert source.release_id == release.contract_release_id assert plan.source_snapshot_id == source.source_snapshot_id assert plan.contract_version == release.contract_version From aadbf27e73c1c97e949fe520f672d336d84395b6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:17:31 +1000 Subject: [PATCH 447/616] test(history): assert greenfield v1 operational schema --- tests/test_operational_history.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_operational_history.py b/tests/test_operational_history.py index c8256a12..a19d386b 100644 --- a/tests/test_operational_history.py +++ b/tests/test_operational_history.py @@ -44,7 +44,7 @@ def test_sqlite_operational_history_is_idempotent(tmp_path) -> None: assert isinstance(sink, SQLiteOperationalHistorySink) event = _event() - assert event.event_version == "2" + assert event.event_version == "1" sink.record_deployment(event) sink.record_deployment(event) From 3f42b125b3318b3e2d4e6ab246b57be3ec81ab66 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:17:51 +1000 Subject: [PATCH 448/616] test(deployment): assert greenfield plan version --- tests/test_deployment_workflow_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index d472121d..090b023b 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -239,7 +239,7 @@ def test_candidate_assessment_does_not_calculate_release_version() -> None: assert bundle.change_set is not None assert bundle.deployment_source.source_kind == "candidate" assert bundle.deployment_source.contract_version == "1.2.3" - assert bundle.deployment_plan.plan_version == "5" + assert bundle.deployment_plan.plan_version == "1" def test_finalized_release_assessment_binds_exact_release_record(tmp_path) -> None: From f733ad71a95b446b8315a39180f465fa126aa9bb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:17:54 +1000 Subject: [PATCH 449/616] docs(deployment): describe greenfield v1 artifact schema --- docs/deployment_plans.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 97da21e0..0eea9f55 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -55,7 +55,7 @@ The orchestration above is provider-neutral. Platform packages configure or impl A `DeploymentPlan` is a deterministic, provider-neutral statement of the runtime state that one exact deployment source intends to converge toward. -Canonical plans are v5 and bind only the immutable `DeploymentSourceSnapshot` plus the target-specific convergence actions. Release provenance and candidate-versus-release mode live only in the source snapshot; they are not duplicated in `DeploymentPlan`. Older deployment-plan shapes are not part of the supported model. +Canonical plans use schema version 1 and bind only the immutable `DeploymentSourceSnapshot` plus target-specific convergence actions. Release provenance and candidate-versus-release mode live only in the source snapshot; they are not duplicated in `DeploymentPlan`. The initial action vocabulary deliberately contains only: From be34a4e0a52394c8fa5e50ead15db316a065d1ee Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:19:47 +1000 Subject: [PATCH 450/616] refactor(application): remove deleted evolution exports --- semapact/application/models/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/semapact/application/models/__init__.py b/semapact/application/models/__init__.py index 7a590aaa..851e6335 100644 --- a/semapact/application/models/__init__.py +++ b/semapact/application/models/__init__.py @@ -9,10 +9,8 @@ from semapact.application.models.evolution import ( BrokenHistoryReference, ContractEvolution, - DeploymentEvolution, ProposalEvolution, ReleaseEvolution, - RuntimeEvolution, ) from semapact.application.models.governance import GovernanceAnalysis, GovernanceProposal from semapact.application.models.history_integrity import HistoryIntegrityReport @@ -22,7 +20,6 @@ __all__ = [ "BrokenHistoryReference", "ContractEvolution", - "DeploymentEvolution", "DeploymentBundle", "DeploymentExecutionResult", "build_deployment_bundle", @@ -33,6 +30,5 @@ "ProposalEvolution", "ReleaseEvolution", "ReleasePlanningResult", - "RuntimeEvolution", "RuntimeReconciliation", ] From 58708e3b96b9e8e748f4076dfd354de7c35ca171 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:19:50 +1000 Subject: [PATCH 451/616] test(cli): import version type from canonical owner --- tests/interfaces/test_cli_outcomes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/interfaces/test_cli_outcomes.py b/tests/interfaces/test_cli_outcomes.py index bad45dcf..f7e4393f 100644 --- a/tests/interfaces/test_cli_outcomes.py +++ b/tests/interfaces/test_cli_outcomes.py @@ -6,7 +6,7 @@ from semapact.change_context import ChangeContext -from semapact.core.release import RequiredBump +from semapact.versioning import RequiredBump from semapact.exceptions import ( GovernanceBlockedError, GovernanceReviewRequiredError, From 17a0ee58885fe57ca945670df88b6a7608a1f59c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:19:53 +1000 Subject: [PATCH 452/616] test(delta): use canonical governance service --- tests/test_delta_import_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_delta_import_integration.py b/tests/test_delta_import_integration.py index 85e67d4e..5aa263eb 100644 --- a/tests/test_delta_import_integration.py +++ b/tests/test_delta_import_integration.py @@ -12,7 +12,7 @@ _extract_delta_relationships, ) from semapact.interfaces.commands.utils import _split_discovered_delta_tables -from semapact.services import GovernanceService +from semapact.application.services.governance import GovernanceService from semapact.utils.storage_adapter import LocalStorageAdapter From 112d1108dd1e86a792aa9f605c0ae58579a67323 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:19:57 +1000 Subject: [PATCH 453/616] test(deployment): use canonical deployment service --- tests/test_deployment_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_deployment_service.py b/tests/test_deployment_service.py index 9a7a3e0a..cbbb0153 100644 --- a/tests/test_deployment_service.py +++ b/tests/test_deployment_service.py @@ -25,7 +25,7 @@ from semapact.exceptions import ValidationError from semapact.observation import ObservedPlatformState, with_observed_state_fingerprint from semapact.reconciliation import ReconciliationResult -from semapact.services.deployment_service import DeploymentService +from semapact.application.services.deployment import DeploymentService CAPTURED_AT = datetime(2026, 9, 10, 10, 0, tzinfo=timezone.utc) SOURCE_REFERENCE = "https://adb.example" From 571300c0cce5fc2628f34610754b6996c49aad6c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:20:00 +1000 Subject: [PATCH 454/616] test(governance): remove obsolete release compatibility paths --- ...test_fail_closed_governance_integration.py | 152 +----------------- 1 file changed, 1 insertion(+), 151 deletions(-) diff --git a/tests/test_fail_closed_governance_integration.py b/tests/test_fail_closed_governance_integration.py index 0d34c5a4..902300c3 100644 --- a/tests/test_fail_closed_governance_integration.py +++ b/tests/test_fail_closed_governance_integration.py @@ -15,9 +15,6 @@ from semapact.core.lifecycle_cli import apply_lifecycle from semapact.devops.ci_cd import evaluate_ci_gate -from semapact.devops.release_workflow import ( - build_batch_release_manifest, -) from semapact.exceptions import GovernanceBlockedError, GovernanceReviewRequiredError from semapact.governance import ( ChangeContext, @@ -26,7 +23,7 @@ ) from semapact.interfaces.commands.merge_cmd import run_merge from semapact.interfaces.commands.plan_cmd import run_plan -from semapact.interfaces.commands.release_cmd import run_release_classify, run_release_prepare +from semapact.interfaces.commands.release_cmd import run_release_classify from semapact.orchestrator.pipeline import ContractPipeline from semapact.utils.schema_utils import contract_to_dict from semapact.utils.yaml_utils import dump_yaml @@ -110,18 +107,6 @@ def test_injected_block_decision_prevents_side_effects(tmp_path, monkeypatch): apply_lifecycle(lifecycle_args, is_promote=True, context=TEST_CONTEXT) assert not (tmp_path / "lifecycle_out.yaml").exists() - # 3. release prepare: raises GovernanceBlockedError and does NOT write candidate - release_prep_args = SimpleNamespace( - base=str(base_path), - candidate=str(candidate_path), - release_tag="v2.0.0", - output=str(tmp_path / "release_out.yaml"), - runtime_context="auto", - effective_date=TEST_EFFECTIVE_DATE, - ) - with pytest.raises(GovernanceBlockedError): - run_release_prepare(release_prep_args) - assert not (tmp_path / "release_out.yaml").exists() # 4. pipeline run: writes decision manifest FIRST, then raises GovernanceBlockedError without generating GE/merged contract pipeline = ContractPipeline() @@ -272,127 +257,7 @@ def mutating_hook(name, **kwargs): assert written_data["id"] == "contract-1", "Written contract must not be mutated by plugin hook" -def test_release_create_pr_block_prevents_all_side_effects(tmp_path): - """Verify create_release_pull_request with a BLOCK decision does not dump YAML, commit/push, or create PR.""" - base = _make_contract(contract_id="contract-a", version="1.0.0", status="active") - candidate = _make_contract(contract_id="contract-a", version="1.0.0", status="active") - from open_data_contract_standard.model import DataQuality - candidate.schema_[0].properties[0].quality = [DataQuality(type="invalid_type")] - - from semapact.devops.pr_creator import GitHubConfig - from semapact.devops.release_workflow import create_release_pull_request - - config = GitHubConfig(owner="org", repo="repo", token="fake") - repo_dir = tmp_path / "repo" - repo_dir.mkdir() - contract_repo_path = "contracts/my_contract.yaml" - - with mock.patch("semapact.devops.pr_creator.PullRequestCreator.create_update_pr") as mock_pr: - with pytest.raises(GovernanceBlockedError) as exc_info: - create_release_pull_request( - config=config, - repo_path=str(repo_dir), - contract_repo_path=contract_repo_path, - base_contract=base, - candidate_contract=candidate, - release_tag="v1.0.1", - source_branch="release/v1.0.1", - target_branch="main", - context=TEST_CONTEXT, - ) - - assert exc_info.value.operation == GovernanceOperation.PROPOSE - assert not (repo_dir / contract_repo_path).exists(), "Candidate YAML must not be dumped when governance gate BLOCKS" - assert mock_pr.call_count == 0, "Pull request creator must not be called when governance gate BLOCKS" - -def test_batch_release_manifest_evaluates_governance_once_per_task(tmp_path, monkeypatch): - """Verify build_batch_release_manifest and create_release_pull_requests_from_manifest evaluate underlying policy exactly ONCE per task per phase without duplicate evaluations.""" - base_dir = tmp_path / "base_root" - cand_dir = tmp_path / "cand_root" - repo_dir = tmp_path / "repo" - base_dir.mkdir() - cand_dir.mkdir() - repo_dir.mkdir() - - for i in range(2): - base_c = _make_contract(contract_id=f"c{i}", version="1.0.0") - cand_c = _make_contract(contract_id=f"c{i}", version="1.0.0") - cand_c.schema_[0].properties.append( - SchemaProperty(name=f"new_{i}", logicalType="string", physicalType="varchar(10)", required=False) - ) - dump_yaml(contract_to_dict(base_c), base_dir / f"c{i}.yaml") - dump_yaml(contract_to_dict(cand_c), cand_dir / f"c{i}.yaml") - - policy_eval_calls = 0 - from semapact.lifecycle.policy import evaluate_merge_policy - orig_eval_policy = evaluate_merge_policy - - def counted_policy_eval(*args, **kwargs): - nonlocal policy_eval_calls - policy_eval_calls += 1 - return orig_eval_policy(*args, **kwargs) - - monkeypatch.setattr("semapact.governance.evaluator.evaluate_merge_policy", counted_policy_eval) - - # 1. Build manifest (2 tasks created, exactly 2 policy evaluations) - build = build_batch_release_manifest( - base_root=str(base_dir), - candidate_root=str(cand_dir), - context=TEST_CONTEXT, - ) - assert len(build.tasks) == 2 - assert policy_eval_calls == 2, f"Expected 2 policy evaluations during build, got {policy_eval_calls}" - - # 2. Run PR creation from manifest tasks (exactly 2 more policy evaluations: 1 per task in evaluate_governance_decision, 0 in apply_release_candidate) - monkeypatch.setattr("semapact.devops.pr_creator.PullRequestCreator.create_update_pr", lambda *a, **k: {}) - from semapact.devops.pr_creator import GitHubConfig - from semapact.devops.release_workflow import create_release_pull_requests_from_manifest - - config = GitHubConfig(owner="org", repo="repo", token="fake") - create_release_pull_requests_from_manifest( - config=config, - repo_path=str(repo_dir), - tasks=build.tasks, - ) - - assert policy_eval_calls == 4, f"Expected exactly 4 total policy evaluations across build+create (1 per task per phase), got {policy_eval_calls}" - - -def test_batch_release_manifest_skips_blocked_contracts(tmp_path): - """Batch release manifest moves contracts with BLOCK decisions to skipped list.""" - base_dir = tmp_path / "base_root" - cand_dir = tmp_path / "cand_root" - base_dir.mkdir() - cand_dir.mkdir() - - base_contract = _make_contract(contract_id="contract-1", version="1.0.0") - cand_valid = _make_contract(contract_id="contract-1", version="1.0.0") - cand_valid.schema_[0].properties.append( - SchemaProperty(name="new_field", logicalType="string", physicalType="varchar(50)", required=False) - ) - dump_yaml(contract_to_dict(base_contract), base_dir / "valid.yaml") - dump_yaml(contract_to_dict(cand_valid), cand_dir / "valid.yaml") - - base_blocked = _make_contract(contract_id="contract-blocked", version="1.0.0") - cand_blocked = _make_contract(contract_id="contract-blocked", version="2.0.0") # Version Mismatch -> BLOCK - dump_yaml(contract_to_dict(base_blocked), base_dir / "blocked.yaml") - dump_yaml(contract_to_dict(cand_blocked), cand_dir / "blocked.yaml") - - build = build_batch_release_manifest( - base_root=str(base_dir), - candidate_root=str(cand_dir), - context=TEST_CONTEXT, - ) - - # Valid additive change produces a task - task_paths = [t.contract_path for t in build.tasks] - assert "valid.yaml" in task_paths - assert "blocked.yaml" not in task_paths - - # Blocked contract is in skipped list - skipped_paths = [s.contract_repo_path for s in build.skipped] - assert "blocked.yaml" in skipped_paths def test_breaking_change_review_behavior(tmp_path, capsys): @@ -432,18 +297,6 @@ def test_breaking_change_review_behavior(tmp_path, capsys): assert class_res["requiredBump"] == "minor" assert class_res["governanceDecision"]["decision"] == "REVIEW" - # 2. PROPOSE (release prepare): Allowed to produce candidate YAML for review - prep_args = SimpleNamespace( - base=str(base_path), - candidate=str(cand_path), - release_tag="v1.1.0", - output=str(tmp_path / "review_candidate.yaml"), - runtime_context="auto", - effective_date=TEST_EFFECTIVE_DATE, - ) - prep_res = run_release_prepare(prep_args) - assert (tmp_path / "review_candidate.yaml").exists() - assert prep_res["governanceDecision"]["decision"] == "REVIEW" # 3. APPLY (lifecycle deprecate property): Deprecating property in active contract -> REVIEW -> Raises GovernanceReviewRequiredError lifecycle_args = SimpleNamespace( @@ -604,6 +457,3 @@ def test_apply_release_candidate_requires_opendatacontractstandard_runtime(): base = _make_contract() candidate_dict = contract_to_dict(base) - from semapact.core.release import apply_release_candidate - with pytest.raises(TypeError, match="candidate_contract must be OpenDataContractStandard"): - apply_release_candidate(base, candidate_dict, "v1.0.1", required_bump="patch") # type: ignore[arg-type] From 296198b77c5c05c64447e394d89b7b396f1cac18 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:20:40 +1000 Subject: [PATCH 455/616] test(governance): remove final obsolete release API cases --- ...test_fail_closed_governance_integration.py | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/tests/test_fail_closed_governance_integration.py b/tests/test_fail_closed_governance_integration.py index 902300c3..b9963e85 100644 --- a/tests/test_fail_closed_governance_integration.py +++ b/tests/test_fail_closed_governance_integration.py @@ -404,29 +404,6 @@ def test_ci_cd_adapter_preserves_allowed_reason(): assert ci_dec.reason == "allowed" -def test_release_prepare_includes_breaking_changes(tmp_path): - """Verify run_release_prepare returns breaking changes when breaking policy violations exist.""" - base = _make_contract(status="active") - candidate = _make_contract(status="active") - # Removing a property from active schema creates a breaking policy change - candidate.schema_[0].properties = [] - - base_path = dump_yaml(contract_to_dict(base), tmp_path / "base.yaml") - cand_path = dump_yaml(contract_to_dict(candidate), tmp_path / "cand.yaml") - - prep_args = SimpleNamespace( - base=str(base_path), - candidate=str(cand_path), - release_tag="v2.0.0", - output=str(tmp_path / "prep_out.yaml"), - runtime_context="auto", - effective_date=TEST_EFFECTIVE_DATE, - ) - - res = run_release_prepare(prep_args) - assert len(res["breakingChanges"]) > 0 - assert any("removed" in str(bc.get("message", "")).lower() or "breaking" in str(bc.get("message", "")).lower() for bc in res["breakingChanges"]) - def test_classify_repo_sets_blocked_status(tmp_path): """Verify classify_contracts_in_repo sets status='blocked' when a contract generates a BLOCK governance decision.""" @@ -440,7 +417,7 @@ def test_classify_repo_sets_blocked_status(tmp_path): dump_yaml(contract_to_dict(base_blocked), base_dir / "blocked.yaml") dump_yaml(contract_to_dict(cand_blocked), cand_dir / "blocked.yaml") - from semapact.devops.release_workflow import classify_contracts_in_repo + from semapact.application.services.repository_classification import classify_contracts_in_repo changes = classify_contracts_in_repo( base_root=str(base_dir), candidate_root=str(cand_dir), From cbe2ed9a5b550b3a1771c2b65b83bf5fe2afcf05 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:21:53 +1000 Subject: [PATCH 456/616] refactor(import): use canonical governance service --- semapact/interfaces/commands/import_cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/interfaces/commands/import_cmd.py b/semapact/interfaces/commands/import_cmd.py index c00cfacf..23eb0fa6 100644 --- a/semapact/interfaces/commands/import_cmd.py +++ b/semapact/interfaces/commands/import_cmd.py @@ -10,7 +10,7 @@ _resolve_adls_oauth_token_from_config, _split_discovered_delta_tables, ) -from semapact.services import GovernanceService +from semapact.application.services.governance import GovernanceService def run_import(args: argparse.Namespace) -> Path: from semapact.core.loader import ContractLoader From 8c145d6382727ce974f7cd3db8ca57fce49d88b8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:21:56 +1000 Subject: [PATCH 457/616] test(reconcile): use canonical application model --- tests/interfaces/test_reconcile_cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/interfaces/test_reconcile_cmd.py b/tests/interfaces/test_reconcile_cmd.py index 0ef576d0..48581c0a 100644 --- a/tests/interfaces/test_reconcile_cmd.py +++ b/tests/interfaces/test_reconcile_cmd.py @@ -18,7 +18,7 @@ RuntimeDriftStatus, RuntimeReasonCode, ) -from semapact.services.reconciliation_service import RuntimeReconciliation +from semapact.application.models.reconciliation import RuntimeReconciliation def _analysis() -> RuntimeReconciliation: From 3bf7a9514125d332a600bcd51067799cec960c62 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:22:00 +1000 Subject: [PATCH 458/616] test(history): align evolution chain with governance ledger --- tests/test_evolution_chain.py | 212 ++++++++-------------------------- 1 file changed, 45 insertions(+), 167 deletions(-) diff --git a/tests/test_evolution_chain.py b/tests/test_evolution_chain.py index 47bd700a..3170ad44 100644 --- a/tests/test_evolution_chain.py +++ b/tests/test_evolution_chain.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from pathlib import Path from open_data_contract_standard.model import ( @@ -12,22 +12,13 @@ from semapact.application.services.evolution import EvolutionChainService from semapact.application.services.governance import GovernanceService from semapact.application.services.history import ProposalHistoryService -from semapact.application.services.runtime_history import RuntimeHistoryService -from semapact.contractops import VersionAuthority, build_change_set -from semapact.history import DeploymentRecord, DeploymentStatus, ReleaseRecord -from semapact.history.integrity import compute_deployment_record_id, compute_release_record_id -from semapact.observation import ( - ObservedAsset, - ObservedAssetIdentity, - ObservedPlatformState, - with_observed_state_fingerprint, -) +from semapact.application.services.release_workflow import ReleaseFinalizer, ReleaseWorkflowService +from semapact.contractops import build_change_set from semapact.platforms.git import GitWorkingTreeHistoryRepository -from semapact.reconciliation import ReconciliationResult from semapact.revision import build_contract_revision -def _contract(*, version: str = "1.2.3", include_note: bool = False) -> OpenDataContractStandard: +def _contract(*, include_note: bool = False) -> OpenDataContractStandard: properties = [ SchemaProperty( name="id", @@ -50,12 +41,22 @@ def _contract(*, version: str = "1.2.3", include_note: bool = False) -> OpenData kind="DataContract", id="orders-product", name="Orders", - version=version, + version="1.2.3", status="active", schema=[SchemaObject(name="orders", properties=properties)], ) +def _service(backend: GitWorkingTreeHistoryRepository) -> EvolutionChainService: + return EvolutionChainService( + revisions=backend, + change_sets=backend, + decisions=backend, + decision_links=backend, + releases=backend, + ) + + def _record_proposal(backend: GitWorkingTreeHistoryRepository): base_revision = build_contract_revision(_contract()) candidate_revision = build_contract_revision(_contract(include_note=True)) @@ -81,156 +82,53 @@ def _record_proposal(backend: GitWorkingTreeHistoryRepository): return proposal, base_revision, candidate_revision -def _release_record(proposal, released_revision_id: str) -> ReleaseRecord: - fields = dict( - contract_id="orders-product", - contract_version="1.3.0", - decision_id=proposal.decision.decision_id, - change_set_id=proposal.change_set.change_set_id, - release_plan_id="release-plan-1", - version_resolution_id="version-resolution-1", - authorization_id="apply-authorization-1", - applied_release_id="applied-release-1", - released_revision_id=released_revision_id, - required_version_bump="minor", - actual_version_bump="minor", - version_authority=VersionAuthority.SEMAPACT, - authority_reference=None, - review_evidence_reference=None, - review_evidence_action=None, - ) - release_record_id = compute_release_record_id( - **{**fields, "version_authority": fields["version_authority"].value} - ) - return ReleaseRecord(release_record_id=release_record_id, **fields) - - -def _deployment_record(release_record_id: str) -> DeploymentRecord: - started = datetime(2026, 9, 13, 1, tzinfo=timezone.utc) - completed = started + timedelta(seconds=2) - fields = dict( - release_record_id=release_record_id, - deployment_plan_id="deployment-plan-1", - deployment_preview_id="deployment-preview-1", - deployment_authorization_id="deployment-authorization-1", - platform="databricks", - runtime_target="catalog.schema", - source_reference="workspace:test", - status=DeploymentStatus.SUCCEEDED, - started_at=started, - completed_at=completed, - actor_reference=None, - external_reference="pipeline:1", - ) - deployment_record_id = compute_deployment_record_id( - release_record_id=release_record_id, - deployment_plan_id=fields["deployment_plan_id"], - deployment_preview_id=fields["deployment_preview_id"], - deployment_authorization_id=fields["deployment_authorization_id"], - platform=fields["platform"], - runtime_target=fields["runtime_target"], - source_reference=fields["source_reference"], - status=fields["status"].value, - started_at=started.isoformat(), - completed_at=completed.isoformat(), - actor_reference=None, - external_reference=fields["external_reference"], - ) - return DeploymentRecord(deployment_record_id=deployment_record_id, **fields) - - -def _observation(at: datetime) -> ObservedPlatformState: - return with_observed_state_fingerprint( - ObservedPlatformState( - platform="databricks", - source_identifier="workspace:test", - assets=( - ObservedAsset( - identity=ObservedAssetIdentity( - platform="databricks", - namespace=("catalog", "schema"), - asset="orders", - ), - asset_type="TABLE", - ), - ), - captured_at=at, - ) - ) - - -def _result(observation: ObservedPlatformState) -> ReconciliationResult: - assert observation.fingerprint is not None - return ReconciliationResult( - contract_id="orders-product", - contract_version="1.3.0", - observation_source_identifier=observation.source_identifier, - observation_fingerprint=observation.fingerprint, +def _record_release( + backend: GitWorkingTreeHistoryRepository, + *, + base_revision, + candidate_revision, +): + workflow = ReleaseWorkflowService() + bundle = workflow.assess( + base_revision.contract, + candidate_revision.contract, + effective_date="2026-09-13", + base_revision_ref=base_revision.revision_id, + candidate_revision_ref=candidate_revision.revision_id, ) - - -def _service(backend: GitWorkingTreeHistoryRepository) -> EvolutionChainService: - return EvolutionChainService( - revisions=backend, - change_sets=backend, - decisions=backend, - decision_links=backend, - releases=backend, - deployments=backend, - observations=backend, - runtime_reconciliations=backend, + approval = workflow.approve( + bundle, + actor_reference="human:test", + recorded_at=datetime(2026, 9, 13, tzinfo=timezone.utc), ) + release = ReleaseFinalizer().finalize(bundle, approval=approval) + backend.put_contract_release(release) + return release -def test_reconstructs_complete_governance_evolution_chain(tmp_path: Path) -> None: +def test_reconstructs_governance_to_contract_release_chain(tmp_path: Path) -> None: backend = GitWorkingTreeHistoryRepository(tmp_path) proposal, base_revision, candidate_revision = _record_proposal(backend) - - released_revision = build_contract_revision( - _contract(version="1.3.0", include_note=True) - ) - backend.put_revision(released_revision) - release = _release_record(proposal, released_revision.revision_id) - backend.put_release_record(release) - deployment = _deployment_record(release.release_record_id) - backend.put_deployment_record(deployment) - - observation = _observation(datetime(2026, 9, 13, 2, tzinfo=timezone.utc)) - runtime = RuntimeHistoryService( - observations=backend, - reconciliations=backend, - releases=backend, - deployments=backend, - ).record_reconciliation( - observation, - _result(observation), - release_record_id=release.release_record_id, - deployment_record_id=deployment.deployment_record_id, + release = _record_release( + backend, + base_revision=base_revision, + candidate_revision=candidate_revision, ) chain = _service(backend).reconstruct("orders-product") assert chain.broken_references == () assert chain.unlinked_releases == () - assert chain.unlinked_runtime == () assert len(chain.proposals) == 1 path = chain.proposals[0] assert path.base_revision == base_revision assert path.candidate_revision == candidate_revision assert path.decision == proposal.decision - assert len(path.releases) == 1 - release_path = path.releases[0] - assert release_path.release == release - assert release_path.released_revision == released_revision - assert len(release_path.deployments) == 1 - deployment_path = release_path.deployments[0] - assert deployment_path.deployment == deployment - assert tuple(item.reconciliation for item in deployment_path.runtime) == (runtime,) - assert deployment_path.runtime[0].observation is not None + assert tuple(item.release for item in path.releases) == (release,) assert _service(backend).reconstruct("orders-product") == chain -def test_incomplete_changeset_history_does_not_fabricate_downstream_stages(tmp_path: Path) -> None: +def test_incomplete_changeset_history_does_not_fabricate_release(tmp_path: Path) -> None: backend = GitWorkingTreeHistoryRepository(tmp_path) proposal, base_revision, candidate_revision = _record_proposal(backend) @@ -245,11 +143,10 @@ def test_incomplete_changeset_history_does_not_fabricate_downstream_stages(tmp_p backend.put_change_set(isolated) chain = _service(backend).reconstruct("orders-product") - isolated_paths = [item for item in chain.proposals if item.change_set == isolated] + isolated_path = next(item for item in chain.proposals if item.change_set == isolated) - assert len(isolated_paths) == 1 - assert isolated_paths[0].decision is None - assert isolated_paths[0].releases == () + assert isolated_path.decision is None + assert isolated_path.releases == () def test_broken_revision_reference_is_reported_explicitly(tmp_path: Path) -> None: @@ -276,22 +173,3 @@ def test_broken_revision_reference_is_reported_explicitly(tmp_path: Path) -> Non and item.reason == "NOT_FOUND" for item in chain.broken_references ) - - -def test_contract_level_runtime_evidence_remains_unlinked(tmp_path: Path) -> None: - backend = GitWorkingTreeHistoryRepository(tmp_path) - observation = _observation(datetime(2026, 9, 13, 3, tzinfo=timezone.utc)) - runtime = RuntimeHistoryService( - observations=backend, - reconciliations=backend, - releases=backend, - deployments=backend, - ).record_reconciliation(observation, _result(observation)) - - chain = _service(backend).reconstruct("orders-product") - - assert chain.proposals == () - assert chain.unlinked_releases == () - assert tuple(item.reconciliation for item in chain.unlinked_runtime) == (runtime,) - assert chain.unlinked_runtime[0].observation is not None - assert chain.broken_references == () From a493eb616c3702d69c4e73594bd0bdb8f042c307 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:23:04 +1000 Subject: [PATCH 459/616] refactor(lifecycle): use canonical governance service --- semapact/interfaces/commands/lifecycle_cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/interfaces/commands/lifecycle_cmd.py b/semapact/interfaces/commands/lifecycle_cmd.py index 4f498876..88b8ab16 100644 --- a/semapact/interfaces/commands/lifecycle_cmd.py +++ b/semapact/interfaces/commands/lifecycle_cmd.py @@ -1,7 +1,7 @@ import argparse from typing import Any -from semapact.services import GovernanceService +from semapact.application.services.governance import GovernanceService def run_lifecycle_promote(args: argparse.Namespace) -> dict[str, Any]: From 0316a49301dd6c3d6f4baeef861c08f4ea39e1c5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:24:17 +1000 Subject: [PATCH 460/616] refactor(deployment): emit greenfield v1 plans --- semapact/deployment/planner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/deployment/planner.py b/semapact/deployment/planner.py index 7ef0cd8e..cee5484a 100644 --- a/semapact/deployment/planner.py +++ b/semapact/deployment/planner.py @@ -41,7 +41,7 @@ def build_deployment_plan_from_source( contract_version=source.contract_version, target=target, actions=ordered_actions, - plan_version="5", + plan_version="1", ) return DeploymentPlan( deployment_plan_id=deployment_plan_id, @@ -51,7 +51,7 @@ def build_deployment_plan_from_source( contract_version=source.contract_version, target=target, actions=ordered_actions, - plan_version="5", + plan_version="1", ) def build_deployment_actions( From b4ff1a6f1806ef7e296c1069fca0198e04742b36 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:26:30 +1000 Subject: [PATCH 461/616] refactor(import): use canonical governance service From c69b00d3a429d43136f4afa515ff8fd9848059a4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:26:34 +1000 Subject: [PATCH 462/616] test(reconcile): use canonical reconciliation model From 4c972ac0cb0618e8280720bc648f02181d220c5f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:26:35 +1000 Subject: [PATCH 463/616] test(cli): remove deleted release prepare subprocess cases --- .../test_cli_subprocess_outcomes.py | 69 ------------------- 1 file changed, 69 deletions(-) diff --git a/tests/interfaces/test_cli_subprocess_outcomes.py b/tests/interfaces/test_cli_subprocess_outcomes.py index 48ebce13..f8048ad1 100644 --- a/tests/interfaces/test_cli_subprocess_outcomes.py +++ b/tests/interfaces/test_cli_subprocess_outcomes.py @@ -198,76 +198,7 @@ def test_subprocess_analyze_release_classify_block( # 2. PROPOSE Commands: ALLOW & REVIEW -> 0, BLOCK -> 3 (GOVERNANCE_BLOCKED) # ============================================================================== -def test_subprocess_propose_release_prepare_review( - active_contract_yaml: Path, review_candidate_yaml: Path, tmp_path: Path -): - out_yaml = tmp_path / "prepared.yaml" - res = _run_cli( - "release", - "prepare", - "--base", - str(active_contract_yaml), - "--candidate", - str(review_candidate_yaml), - "--release-tag", - "v1.1.0", - "--output", - str(out_yaml), - "--effective-date", - "2026-08-29", - ) - assert res.returncode == 0 - payload = json.loads(res.stdout) - assert payload["actualBump"] == "minor" - assert "exitCode" not in payload - -def test_subprocess_propose_release_prepare_no_bump_validation_failed( - active_contract_yaml: Path, tmp_path: Path -): - out_yaml = tmp_path / "prepared.yaml" - res = _run_cli( - "release", - "prepare", - "--base", - str(active_contract_yaml), - "--candidate", - str(active_contract_yaml), - "--release-tag", - "v1.0.0", - "--output", - str(out_yaml), - "--effective-date", - "2026-08-29", - ) - # Attempting to prepare a release candidate when no bump is required returns VALIDATION_FAILED (2) - assert res.returncode == 2 - assert "Contract changes do not require a release version bump" in res.stderr - - - -def test_subprocess_propose_release_prepare_block( - retired_contract_yaml: Path, review_candidate_yaml: Path, tmp_path: Path -): - out_yaml = tmp_path / "prepared.yaml" - res = _run_cli( - "release", - "prepare", - "--base", - str(retired_contract_yaml), - "--candidate", - str(review_candidate_yaml), - "--release-tag", - "v2.0.0", - "--output", - str(out_yaml), - "--effective-date", - "2026-08-29", - ) - # PROPOSE with BLOCK decision must exit with 3 (GOVERNANCE_BLOCKED) - assert res.returncode == 3 - assert "Governance decision BLOCKED" in res.stderr - assert "Traceback (most recent call last)" not in res.stderr def test_subprocess_propose_merge_block( From c36a56ff7c359461e47f4b12d8a2862f913ce4fb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:26:37 +1000 Subject: [PATCH 464/616] test(history): use canonical governance evolution boundary --- tests/test_history_integrity.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_history_integrity.py b/tests/test_history_integrity.py index e9f6cb46..0dc8ed7b 100644 --- a/tests/test_history_integrity.py +++ b/tests/test_history_integrity.py @@ -76,9 +76,6 @@ def _evolution_service(backend: GitWorkingTreeHistoryRepository) -> EvolutionCha decisions=backend, decision_links=backend, releases=backend, - deployments=backend, - observations=backend, - runtime_reconciliations=backend, ) From 856ac922afea2773c5f5ef0a1039c6aa26f7b791 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:26:42 +1000 Subject: [PATCH 465/616] test(cli): remove obsolete release promotion surfaces --- tests/test_semapact_cli_readable.py | 236 ---------------------------- 1 file changed, 236 deletions(-) diff --git a/tests/test_semapact_cli_readable.py b/tests/test_semapact_cli_readable.py index d2377d8b..ccee513c 100644 --- a/tests/test_semapact_cli_readable.py +++ b/tests/test_semapact_cli_readable.py @@ -336,58 +336,6 @@ def test_cli_release_classify_outputs_per_contract_required_bump( assert payload["hasChanges"] is True -def test_cli_release_prepare_outputs_promoted_contract( - sample_odcs_model, tmp_path, capsys, monkeypatch -): - base_contract = sample_odcs_model.model_copy(deep=True) - candidate_contract = sample_odcs_model.model_copy(deep=True) - assert candidate_contract.schema_ is not None - assert candidate_contract.schema_[0].properties is not None - candidate_contract.schema_[0].properties.append( - SchemaProperty( - id="new_optional_column", - name="new_optional_column", - physicalName="new_optional_column", - logicalType="string", - physicalType="STRING", - required=False, - ) - ) - - base_path = dump_yaml(base_contract, tmp_path / "base.yaml") - candidate_path = dump_yaml(candidate_contract, tmp_path / "candidate.yaml") - output_path = tmp_path / "promoted.yaml" - - monkeypatch.setattr( - "sys.argv", - [ - "semapact", - "release", - "prepare", - "--base", - str(base_path), - "--candidate", - str(candidate_path), - "--release-tag", - "orders/v1.2.0", - "--output", - str(output_path), - "--effective-date", - TEST_EFFECTIVE_DATE, - ], - ) - - exit_code = cli.main() - payload = json.loads(capsys.readouterr().out) - promoted = load_yaml(output_path) - - assert exit_code == 0 - assert payload["requiredBump"] == "minor" - assert payload["actualBump"] == "minor" - assert payload["targetVersion"] == "1.2.0" - assert promoted["version"] == "1.2.0" - assert promoted["id"] == str(base_contract.id) - def test_cli_release_classify_repo_outputs_per_contract_results( sample_odcs_model, tmp_path, capsys, monkeypatch @@ -429,190 +377,6 @@ def test_cli_release_classify_repo_outputs_per_contract_results( assert by_path["changed.yaml"]["suggested_release_version"] is None -def test_cli_release_create_prs_outputs_batch_payload( - sample_odcs_model, tmp_path, capsys, monkeypatch -): - repo_path = tmp_path / "repo" - repo_path.mkdir() - base_contract = sample_odcs_model.model_copy(deep=True) - candidate_contract = sample_odcs_model.model_copy(deep=True) - assert candidate_contract.description is not None - candidate_contract.description.usage = "Updated descriptive text only" - - base_path = dump_yaml(base_contract, tmp_path / "base.yaml") - candidate_path = dump_yaml(candidate_contract, tmp_path / "candidate.yaml") - manifest_path = tmp_path / "manifest.json" - manifest_path.write_text( - json.dumps( - [ - { - "base": str(base_path), - "candidate": str(candidate_path), - "contract_path": "contracts/orders.yaml", - "release_tag": "orders/v1.1.1", - "source_branch": "release/orders-v1.1.1", - "target_branch": "release", - "effective_date": TEST_EFFECTIVE_DATE, - } - ] - ), - encoding="utf-8", - ) - - captured_kwargs: dict[str, Any] = {} - - def _fake_create_prs_from_manifest(**kwargs): - captured_kwargs.update(kwargs) - return [ - { - "promotion": { - "contractId": str(base_contract.id), - "targetVersion": "1.1.1", - }, - "pullRequest": {"pullRequestId": 88}, - } - ] - - monkeypatch.setattr( - "semapact.devops.release_workflow.create_release_pull_requests_from_manifest", - _fake_create_prs_from_manifest, - ) - - # Mock the config manager with fallback values that should be overridden - config_vals = { - "git.provider": "github", - "git.organization": "fallback-org", - "git.project": "fallback-proj", - "git.repository_id": "fallback-repo", - "git.pat_token": "fallback-token", - } - monkeypatch.setattr( - "semapact.core.config.config_manager.get", - lambda key, *args, **kwargs: config_vals.get(key, kwargs.get("default")), - ) - - monkeypatch.setattr( - "sys.argv", - [ - "semapact", - "release", - "create-prs", - "--manifest", - str(manifest_path), - "--repo-path", - str(repo_path), - "--git-provider", - "azure", - "--organization", - "org", - "--project", - "proj", - "--repository-id", - "repo", - "--pat-token", - "token", - ], - ) - - exit_code = cli.main() - payload = json.loads(capsys.readouterr().out) - - assert exit_code == 0 - assert payload["results"][0]["pullRequest"]["pullRequestId"] == 88 - assert payload["tasks"][0]["release_tag"] == "orders/v1.1.1" - assert payload["tasks"][0]["effective_date"] == TEST_EFFECTIVE_DATE - - # Verify that CLI arguments overrode the config fallbacks - assert type(captured_kwargs["config"]).__name__ == "AzureDevOpsConfig" - assert captured_kwargs["config"].organization == "org" - assert captured_kwargs["config"].project == "proj" - assert captured_kwargs["config"].repository_id == "repo" - assert captured_kwargs["config"].pat_token == "token" - - -def test_cli_release_create_prs_uses_config_fallback( - sample_odcs_model, tmp_path, capsys, monkeypatch -): - repo_path = tmp_path / "repo" - repo_path.mkdir() - base_contract = sample_odcs_model.model_copy(deep=True) - candidate_contract = sample_odcs_model.model_copy(deep=True) - assert candidate_contract.description is not None - candidate_contract.description.usage = "Updated descriptive text only" - - base_path = dump_yaml(base_contract, tmp_path / "base.yaml") - candidate_path = dump_yaml(candidate_contract, tmp_path / "candidate.yaml") - manifest_path = tmp_path / "manifest.json" - manifest_path.write_text( - json.dumps( - [ - { - "base": str(base_path), - "candidate": str(candidate_path), - "contract_path": "contracts/orders.yaml", - "release_tag": "orders/v1.1.1", - "source_branch": "release/orders-v1.1.1", - "target_branch": "release", - "effective_date": TEST_EFFECTIVE_DATE, - } - ] - ), - encoding="utf-8", - ) - - captured_kwargs: dict[str, Any] = {} - - def _fake_create_prs_from_manifest(**kwargs): - captured_kwargs.update(kwargs) - return [ - { - "promotion": { - "contractId": str(base_contract.id), - "targetVersion": "1.1.1", - }, - "pullRequest": {"pullRequestId": 88}, - } - ] - - monkeypatch.setattr( - "semapact.devops.release_workflow.create_release_pull_requests_from_manifest", - _fake_create_prs_from_manifest, - ) - - # Mock the config manager - config_vals = { - "git.provider": "github", - "git.github_owner": "fallback-owner", - "git.github_repo": "fallback-repo", - "git.github_token": "fallback-token", - } - monkeypatch.setattr( - "semapact.core.config.config_manager.get", - lambda key, *args, **kwargs: config_vals.get(key, kwargs.get("default")), - ) - - monkeypatch.setattr( - "sys.argv", - [ - "semapact", - "release", - "create-prs", - "--manifest", - str(manifest_path), - "--repo-path", - str(repo_path), - # Notice we are omitting --organization, --project, --github-owner, etc. - ], - ) - - exit_code = cli.main() - json.loads(capsys.readouterr().out) - - assert exit_code == 0 - assert type(captured_kwargs["config"]).__name__ == "GitHubConfig" - assert captured_kwargs["config"].owner == "fallback-owner" - assert captured_kwargs["config"].repo == "fallback-repo" - assert captured_kwargs["config"].token == "fallback-token" def test_cli_release_build_manifest_writes_json_array_and_summary( From 30fe0ff8a70aeb59d9770af0c1fc949203d57ccd Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:26:43 +1000 Subject: [PATCH 466/616] test(history): remove obsolete runtime evolution chain --- tests/test_evolution_chain.py | 175 ---------------------------------- 1 file changed, 175 deletions(-) delete mode 100644 tests/test_evolution_chain.py diff --git a/tests/test_evolution_chain.py b/tests/test_evolution_chain.py deleted file mode 100644 index 3170ad44..00000000 --- a/tests/test_evolution_chain.py +++ /dev/null @@ -1,175 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from pathlib import Path - -from open_data_contract_standard.model import ( - OpenDataContractStandard, - SchemaObject, - SchemaProperty, -) - -from semapact.application.services.evolution import EvolutionChainService -from semapact.application.services.governance import GovernanceService -from semapact.application.services.history import ProposalHistoryService -from semapact.application.services.release_workflow import ReleaseFinalizer, ReleaseWorkflowService -from semapact.contractops import build_change_set -from semapact.platforms.git import GitWorkingTreeHistoryRepository -from semapact.revision import build_contract_revision - - -def _contract(*, include_note: bool = False) -> OpenDataContractStandard: - properties = [ - SchemaProperty( - name="id", - logicalType="string", - physicalType="varchar(255)", - required=True, - ) - ] - if include_note: - properties.append( - SchemaProperty( - name="note", - logicalType="string", - physicalType="varchar(255)", - required=False, - ) - ) - return OpenDataContractStandard( - apiVersion="v3.1.0", - kind="DataContract", - id="orders-product", - name="Orders", - version="1.2.3", - status="active", - schema=[SchemaObject(name="orders", properties=properties)], - ) - - -def _service(backend: GitWorkingTreeHistoryRepository) -> EvolutionChainService: - return EvolutionChainService( - revisions=backend, - change_sets=backend, - decisions=backend, - decision_links=backend, - releases=backend, - ) - - -def _record_proposal(backend: GitWorkingTreeHistoryRepository): - base_revision = build_contract_revision(_contract()) - candidate_revision = build_contract_revision(_contract(include_note=True)) - proposal = GovernanceService().evaluate_proposal( - base_revision.contract, - candidate_revision.contract, - effective_date="2026-09-13", - base_revision_ref=base_revision.revision_id, - candidate_revision_ref=candidate_revision.revision_id, - source="test", - actor_reference="actor:test", - ) - ProposalHistoryService( - revisions=backend, - change_sets=backend, - decisions=backend, - decision_links=backend, - ).record_proposal( - proposal, - base_revision=base_revision, - candidate_revision=candidate_revision, - ) - return proposal, base_revision, candidate_revision - - -def _record_release( - backend: GitWorkingTreeHistoryRepository, - *, - base_revision, - candidate_revision, -): - workflow = ReleaseWorkflowService() - bundle = workflow.assess( - base_revision.contract, - candidate_revision.contract, - effective_date="2026-09-13", - base_revision_ref=base_revision.revision_id, - candidate_revision_ref=candidate_revision.revision_id, - ) - approval = workflow.approve( - bundle, - actor_reference="human:test", - recorded_at=datetime(2026, 9, 13, tzinfo=timezone.utc), - ) - release = ReleaseFinalizer().finalize(bundle, approval=approval) - backend.put_contract_release(release) - return release - - -def test_reconstructs_governance_to_contract_release_chain(tmp_path: Path) -> None: - backend = GitWorkingTreeHistoryRepository(tmp_path) - proposal, base_revision, candidate_revision = _record_proposal(backend) - release = _record_release( - backend, - base_revision=base_revision, - candidate_revision=candidate_revision, - ) - - chain = _service(backend).reconstruct("orders-product") - - assert chain.broken_references == () - assert chain.unlinked_releases == () - assert len(chain.proposals) == 1 - path = chain.proposals[0] - assert path.base_revision == base_revision - assert path.candidate_revision == candidate_revision - assert path.decision == proposal.decision - assert tuple(item.release for item in path.releases) == (release,) - assert _service(backend).reconstruct("orders-product") == chain - - -def test_incomplete_changeset_history_does_not_fabricate_release(tmp_path: Path) -> None: - backend = GitWorkingTreeHistoryRepository(tmp_path) - proposal, base_revision, candidate_revision = _record_proposal(backend) - - isolated = build_change_set( - contract_id=proposal.change_set.contract_id, - base_revision_ref=base_revision.revision_id, - candidate_revision_ref=candidate_revision.revision_id, - changes=proposal.change_set.changes, - context=proposal.change_set.context, - source="isolated", - ) - backend.put_change_set(isolated) - - chain = _service(backend).reconstruct("orders-product") - isolated_path = next(item for item in chain.proposals if item.change_set == isolated) - - assert isolated_path.decision is None - assert isolated_path.releases == () - - -def test_broken_revision_reference_is_reported_explicitly(tmp_path: Path) -> None: - backend = GitWorkingTreeHistoryRepository(tmp_path) - proposal, _, candidate_revision = _record_proposal(backend) - broken = build_change_set( - contract_id="orders-product", - base_revision_ref="missing-revision", - candidate_revision_ref=candidate_revision.revision_id, - changes=proposal.change_set.changes, - context=proposal.change_set.context, - source="broken", - ) - backend.put_change_set(broken) - - chain = _service(backend).reconstruct("orders-product") - path = next(item for item in chain.proposals if item.change_set == broken) - - assert path.base_revision is None - assert any( - item.source_id == broken.change_set_id - and item.reference_field == "base_revision_ref" - and item.target_id == "missing-revision" - and item.reason == "NOT_FOUND" - for item in chain.broken_references - ) From 2b539857bcdfc6b3f70a7cc62174cc4b7badab69 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:26:46 +1000 Subject: [PATCH 467/616] test(history): align integrity evolution with governance ledger From 60c832fa40067734af8f4a0671b449fb5894c790 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:26:56 +1000 Subject: [PATCH 468/616] test(lifecycle): remove noncanonical contract lifecycle fallback --- tests/test_retired_immutability.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/tests/test_retired_immutability.py b/tests/test_retired_immutability.py index 27c8997e..52c28f7c 100644 --- a/tests/test_retired_immutability.py +++ b/tests/test_retired_immutability.py @@ -127,25 +127,6 @@ def test_retired_descriptive_metadata_change_blocks(self) -> None: for r in decision.reasons ) - def test_retired_via_custom_properties_effective_lifecycle_blocks(self) -> None: - """Effective lifecycle from customProperties.lifecycleStatus=retired blocks candidate mutation.""" - base = _make_retired_contract() - base.status = None - base.customProperties = [ - CustomProperty(property="lifecycleStatus", value="retired") - ] - - candidate = base.model_copy(deep=True) - candidate.description = Description(usage="Mutated candidate description") - - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) - - assert decision.decision == DecisionResult.BLOCK - assert decision.policy.retired_violation is True - assert any( - r.code == GovernanceReasonCode.RETIRED_CONTRACT_MODIFIED - for r in decision.reasons - ) def test_retired_schema_change_blocks(self) -> None: """Adding a new schema object to a retired contract produces BLOCK.""" From 4d8249f085c3096c05076370f52f2cf513ebb6f8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:27:01 +1000 Subject: [PATCH 469/616] test(contractops): assert fail-closed tampered version identity --- tests/test_contractops_execution.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_contractops_execution.py b/tests/test_contractops_execution.py index 8ec60106..7d565109 100644 --- a/tests/test_contractops_execution.py +++ b/tests/test_contractops_execution.py @@ -147,7 +147,7 @@ def test_release_snapshot_fails_closed_when_candidate_version_drifted() -> None: ) -def test_release_snapshot_rejects_noncanonical_semver_input() -> None: +def test_release_snapshot_rejects_tampered_version_resolution() -> None: candidate, decision, change_set, release_plan, version_resolution = ( _release_context() ) @@ -155,7 +155,7 @@ def test_release_snapshot_rejects_noncanonical_semver_input() -> None: update={"selected_version": "v1.0.1"} ) - with pytest.raises(ReleaseValidationError, match="canonical"): + with pytest.raises(ReleaseValidationError, match="deterministic identity"): build_release_snapshot( candidate, candidate_revision_ref="rev:candidate", From 40bfa2b0c8c90de7386381382821ee9a18906dc7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:27:50 +1000 Subject: [PATCH 470/616] test(governance): refresh canonical release guidance fixture --- .../governance_scenarios/manual_version_change/expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/governance_scenarios/manual_version_change/expected.json b/tests/fixtures/governance_scenarios/manual_version_change/expected.json index cf3cc1c7..ace47285 100644 --- a/tests/fixtures/governance_scenarios/manual_version_change/expected.json +++ b/tests/fixtures/governance_scenarios/manual_version_change/expected.json @@ -38,7 +38,7 @@ { "code": "CONTRACT_VERSION_MANUALLY_CHANGED", "details": {}, - "message": "Contract version mismatch. Contract versions are release-managed and cannot be manually updated during normal import/merge. Please revert the version change and use 'semapact release prepare'.", + "message": "Contract version mismatch. Contract versions are release-managed and cannot be manually updated during normal import/merge. Please revert the version change; SemaPact sets the release version during 'semapact release finalize'.", "path": "version", "severity": "ERROR" } @@ -59,7 +59,7 @@ { "code": "CONTRACT_VERSION_MANUALLY_CHANGED", "details": {}, - "message": "Contract version mismatch. Contract versions are release-managed and cannot be manually updated during normal import/merge. Please revert the version change and use 'semapact release prepare'.", + "message": "Contract version mismatch. Contract versions are release-managed and cannot be manually updated during normal import/merge. Please revert the version change; SemaPact sets the release version during 'semapact release finalize'.", "path": "version", "severity": "ERROR" } From e9e423d705a9b76ce0a41d088f5283ee2c5d1730 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:31:21 +1000 Subject: [PATCH 471/616] test(cli): remove obsolete release manifest regression --- tests/test_semapact_cli_readable.py | 57 ----------------------------- 1 file changed, 57 deletions(-) diff --git a/tests/test_semapact_cli_readable.py b/tests/test_semapact_cli_readable.py index ccee513c..189306f3 100644 --- a/tests/test_semapact_cli_readable.py +++ b/tests/test_semapact_cli_readable.py @@ -378,60 +378,3 @@ def test_cli_release_classify_repo_outputs_per_contract_results( - -def test_cli_release_build_manifest_writes_json_array_and_summary( - sample_odcs_model, tmp_path, capsys, monkeypatch -): - base_root = tmp_path / "base" - candidate_root = tmp_path / "candidate" - output_path = tmp_path / "release_manifest.json" - - docs_only = sample_odcs_model.model_copy(deep=True) - assert docs_only.description is not None - docs_only.description.usage = "Updated descriptive text only" - - additive = sample_odcs_model.model_copy(deep=True) - assert additive.schema_ is not None - assert additive.schema_[0].properties is not None - additive.schema_[0].properties.append( - SchemaProperty( - name="new_optional_column", - logicalType="string", - physicalType="STRING", - required=False, - ) - ) - - dump_yaml(sample_odcs_model, base_root / "orders.yaml") - dump_yaml(docs_only, candidate_root / "orders.yaml") - dump_yaml(sample_odcs_model, base_root / "payments.yaml") - dump_yaml(additive, candidate_root / "payments.yaml") - - monkeypatch.setattr( - "sys.argv", - [ - "semapact", - "release", - "build-manifest", - "--base-root", - str(base_root), - "--candidate-root", - str(candidate_root), - "--output", - str(output_path), - "--effective-date", - TEST_EFFECTIVE_DATE, - ], - ) - - exit_code = cli.main() - payload = json.loads(capsys.readouterr().out) - manifest = json.loads(output_path.read_text(encoding="utf-8")) - - assert exit_code == 0 - assert payload["output"] == str(output_path.resolve()) - assert payload["tasks"][0]["contract_path"] == "payments.yaml" - assert payload["tasks"][0]["effective_date"] == TEST_EFFECTIVE_DATE - assert payload["skipped"][0]["contract_repo_path"] == "orders.yaml" - assert manifest[0]["release_tag"].endswith("/v1.2.0") - assert manifest[0]["effective_date"] == TEST_EFFECTIVE_DATE From 4a8393593244d6eccff07cd6af8cb2afe9bc8d11 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:32:22 +1000 Subject: [PATCH 472/616] test(cicd): execute canonical release and deployment artifact handoff --- tests/test_cicd_workflow_regression.py | 347 +++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 tests/test_cicd_workflow_regression.py diff --git a/tests/test_cicd_workflow_regression.py b/tests/test_cicd_workflow_regression.py new file mode 100644 index 00000000..d66d36c5 --- /dev/null +++ b/tests/test_cicd_workflow_regression.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone + +from open_data_contract_standard.model import ( + OpenDataContractStandard, + SchemaObject, + SchemaProperty, + Server, +) + +from semapact.deployment import ( + DeploymentAdapter, + DeploymentPreview, + NativeOperation, + NativeOperationKind, +) +from semapact.deployment.models import compute_deployment_preview_id +from semapact.interfaces import cli +from semapact.observation import ObservedPlatformState, with_observed_state_fingerprint +from semapact.reconciliation import ReconciliationResult +from semapact.utils.yaml_utils import dump_yaml + + +SOURCE_REFERENCE = "https://workspace.example" + + +class _PreviewAdapter(DeploymentAdapter): + key = "databricks" + + def validate(self, plan) -> None: + pass + + def preview(self, plan) -> DeploymentPreview: + observation = with_observed_state_fingerprint( + ObservedPlatformState( + platform="databricks", + source_identifier=plan.target.source_reference, + assets=(), + captured_at=datetime(2026, 9, 21, tzinfo=timezone.utc), + ) + ) + assert observation.fingerprint is not None + operations = ( + NativeOperation( + kind=NativeOperationKind.CREATE, + governed_asset="orders", + statement="CREATE orders", + ), + ) + return DeploymentPreview( + deployment_preview_id=compute_deployment_preview_id( + deployment_plan_id=plan.deployment_plan_id, + platform="databricks", + runtime_target=plan.target.runtime_target, + source_identifier=observation.source_identifier, + observation_fingerprint=observation.fingerprint, + operations=operations, + ), + deployment_plan_id=plan.deployment_plan_id, + platform="databricks", + runtime_target=plan.target.runtime_target, + source_identifier=observation.source_identifier, + observation_fingerprint=observation.fingerprint, + operations=operations, + ) + + def apply(self, plan, preview) -> None: + raise AssertionError("CI assessment must not mutate runtime") + + def verify(self, plan) -> ReconciliationResult: + raise AssertionError("CI assessment must not verify runtime") + + +class _ExecutionAdapter(DeploymentAdapter): + key = "databricks" + + def __init__(self) -> None: + self.preview_calls = 0 + self.apply_calls = 0 + self.verify_calls = 0 + self.metadata_calls = 0 + + def validate(self, plan) -> None: + pass + + def preview(self, plan) -> DeploymentPreview: + self.preview_calls += 1 + observation = with_observed_state_fingerprint( + ObservedPlatformState( + platform="databricks", + source_identifier=plan.target.source_reference, + assets=(), + captured_at=datetime(2026, 9, 21, 1, tzinfo=timezone.utc), + ) + ) + assert observation.fingerprint is not None + operations = ( + NativeOperation( + kind=NativeOperationKind.NO_OP, + governed_asset="orders", + ), + ) + return DeploymentPreview( + deployment_preview_id=compute_deployment_preview_id( + deployment_plan_id=plan.deployment_plan_id, + platform="databricks", + runtime_target=plan.target.runtime_target, + source_identifier=observation.source_identifier, + observation_fingerprint=observation.fingerprint, + operations=operations, + ), + deployment_plan_id=plan.deployment_plan_id, + platform="databricks", + runtime_target=plan.target.runtime_target, + source_identifier=observation.source_identifier, + observation_fingerprint=observation.fingerprint, + operations=operations, + ) + + def apply(self, plan, preview) -> None: + self.apply_calls += 1 + + def verify(self, plan) -> ReconciliationResult: + self.verify_calls += 1 + return ReconciliationResult( + contract_id=plan.contract_id, + contract_version=plan.contract_version, + observation_source_identifier=plan.target.source_reference, + observation_fingerprint="obs-v1:sha256:verified", + ) + + def project_release_metadata(self, plan, metadata) -> None: + self.metadata_calls += 1 + + +def _server(name: str, schema_name: str) -> Server: + return Server.model_validate( + { + "server": name, + "type": "databricks", + "host": SOURCE_REFERENCE, + "catalog": "main", + "schema": schema_name, + } + ) + + +def _contract(*, include_created_at: bool = False) -> OpenDataContractStandard: + properties = [ + SchemaProperty( + name="id", + logicalType="string", + physicalType="varchar(255)", + required=True, + ) + ] + if include_created_at: + properties.append( + SchemaProperty( + name="created_at", + logicalType="timestamp", + physicalType="timestamp", + required=False, + ) + ) + return OpenDataContractStandard( + apiVersion="v3.1.0", + kind="DataContract", + id="orders-product", + name="Orders", + version="1.2.3", + status="active", + servers=[ + _server("development", "development"), + _server("production", "production"), + ], + schema=[SchemaObject(name="orders", properties=properties)], + ) + + +def _run_cli(monkeypatch, capsys, *args: str) -> tuple[int, str]: + monkeypatch.setattr("sys.argv", ["semapact", *args]) + code = cli.main() + output = capsys.readouterr().out + return code, output + + +def test_data_product_sample_command_chain_executes_end_to_end( + tmp_path, + monkeypatch, + capsys, +) -> None: + """Exercise the same public artifact handoff used by the sample GitHub pipeline.""" + base_path = dump_yaml(_contract(), tmp_path / "base.yaml") + candidate_path = dump_yaml( + _contract(include_created_at=True), + tmp_path / "contract.yaml", + ) + candidate_bundle = tmp_path / "candidate.deployment.bundle.json" + release_bundle = tmp_path / "release.bundle.json" + released_contract = tmp_path / "released.yaml" + release_artifact = tmp_path / "contract-release.json" + production_bundle = tmp_path / "production.deployment.bundle.json" + + execution_adapter = _ExecutionAdapter() + + def _adapter_factory(platform: str, **kwargs): + assert platform == "databricks" + if kwargs.get("execution_config") is None: + return _PreviewAdapter() + return execution_adapter + + monkeypatch.setattr( + "semapact.platforms.runtime_registry.create_deployment_adapter", + _adapter_factory, + ) + monkeypatch.setattr( + "semapact.core.config.config_manager.get", + lambda *args, **kwargs: None, + ) + + # Pull-request candidate assessment: read-only and artifact-producing. + code, _ = _run_cli( + monkeypatch, + capsys, + "deployment", + "assess", + "--base", + str(base_path), + "--candidate", + str(candidate_path), + "--base-revision-ref", + "git:base", + "--candidate-revision-ref", + "git:candidate", + "--effective-date", + "2026-09-21", + "--server", + "development", + "--bundle-out", + str(candidate_bundle), + "--output", + "json", + ) + assert code == 0 + assert candidate_bundle.is_file() + + # Main-branch formal release assessment. + code, release_output = _run_cli( + monkeypatch, + capsys, + "release", + "assess", + "--base", + str(base_path), + "--candidate", + str(candidate_path), + "--base-revision-ref", + "git:base", + "--candidate-revision-ref", + "git:candidate", + "--effective-date", + "2026-09-21", + "--bundle-out", + str(release_bundle), + ) + assert code == 0 + assert json.loads(release_output)["decision"]["decision"] == "REVIEW" + assert release_bundle.is_file() + + # Protected release environment records approval for the exact bundle. + code, _ = _run_cli( + monkeypatch, + capsys, + "release", + "approve", + "--bundle", + str(release_bundle), + "--actor-reference", + "github-environment:contract-release/run:1", + "--recorded-at", + "2026-09-21T04:00:00Z", + "--repository-root", + str(tmp_path), + ) + assert code == 0 + + # Finalization materializes ODCS and emits the immutable ContractRelease artifact. + code, finalize_output = _run_cli( + monkeypatch, + capsys, + "release", + "finalize", + "--bundle", + str(release_bundle), + "--output-contract", + str(released_contract), + "--release-out", + str(release_artifact), + "--repository-root", + str(tmp_path), + ) + assert code == 0 + finalized = json.loads(finalize_output) + assert finalized["contractVersion"] == "1.3.0" + assert release_artifact.is_file() + assert released_contract.is_file() + + # Production assessment consumes the ContractRelease artifact directly. + code, _ = _run_cli( + monkeypatch, + capsys, + "deployment", + "assess", + "--release", + str(release_artifact), + "--server", + "production", + "--bundle-out", + str(production_bundle), + ) + assert code == 0 + assert production_bundle.is_file() + + # Protected production environment consumes the exact target bundle. + code, deploy_output = _run_cli( + monkeypatch, + capsys, + "deployment", + "deploy", + "--bundle", + str(production_bundle), + "--warehouse-id", + "warehouse-1", + "--output", + "json", + ) + assert code == 0 + deployed = json.loads(deploy_output) + assert deployed["status"] == "IN_SYNC" + assert deployed["contract_release_id"] == finalized["contractReleaseId"] + assert execution_adapter.preview_calls == 1 + assert execution_adapter.apply_calls == 1 + assert execution_adapter.verify_calls == 1 + assert execution_adapter.metadata_calls == 1 From bfeb09e357dcbfac1f65592061052c80b2e93d63 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:33:06 +1000 Subject: [PATCH 473/616] refactor(cicd): emit collision-resistant contract artifact keys --- .../application/services/repository_classification.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/semapact/application/services/repository_classification.py b/semapact/application/services/repository_classification.py index 6ec02d8a..5ae6cd9a 100644 --- a/semapact/application/services/repository_classification.py +++ b/semapact/application/services/repository_classification.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +import hashlib from pathlib import Path from typing import Any @@ -115,6 +116,8 @@ def repository_change_to_dict(change: RepositoryContractChange) -> dict[str, Any return { "contract_repo_path": change.contract_repo_path, "contractRepoPath": change.contract_repo_path, + "artifact_key": _artifact_key(change.contract_repo_path), + "artifactKey": _artifact_key(change.contract_repo_path), "status": change.status, "contract_id": change.contract_id, "contractId": change.contract_id, @@ -139,3 +142,8 @@ def _relative_contract_index(root: Path) -> dict[str, Path]: str(path.relative_to(root)): path for path in (Path(item) for item in list_yaml_documents(root)) } + + +def _artifact_key(contract_repo_path: str) -> str: + """Return a collision-resistant filesystem/artifact key for one repo-relative path.""" + return hashlib.sha256(contract_repo_path.encode("utf-8")).hexdigest() From 6a66a4de296e60cc06e858e9663fbb7765edb802 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:33:10 +1000 Subject: [PATCH 474/616] fix(cicd): use collision-resistant central-repo artifact keys --- examples/github/central-contract-repo-ci-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/github/central-contract-repo-ci-cd.yml b/examples/github/central-contract-repo-ci-cd.yml index 798f71dd..deb8c1a1 100644 --- a/examples/github/central-contract-repo-ci-cd.yml +++ b/examples/github/central-contract-repo-ci-cd.yml @@ -84,7 +84,7 @@ jobs: run: | MATRIX=$(jq -c '{include: [.contracts[] | select(.status == "changed") - | {contract_path: .contractRepoPath, artifact: (.contractRepoPath | gsub("[^A-Za-z0-9_-]"; "_"))}]}' artifacts/repository-changes.json) + | {contract_path: .contractRepoPath, artifact: .artifactKey}]}' artifacts/repository-changes.json) COUNT=$(echo "${MATRIX}" | jq '.include | length') echo "matrix=${MATRIX}" >> "${GITHUB_OUTPUT}" if [ "${COUNT}" -gt 0 ]; then From 4f333e6988839cab68dc3905de688fed28272507 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:33:35 +1000 Subject: [PATCH 475/616] test(cicd): execute central-repo release and deployment fan-out --- tests/test_cicd_workflow_regression.py | 163 ++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/tests/test_cicd_workflow_regression.py b/tests/test_cicd_workflow_regression.py index d66d36c5..d29f7d03 100644 --- a/tests/test_cicd_workflow_regression.py +++ b/tests/test_cicd_workflow_regression.py @@ -147,7 +147,7 @@ def _server(name: str, schema_name: str) -> Server: ) -def _contract(*, include_created_at: bool = False) -> OpenDataContractStandard: +def _contract(\n *,\n contract_id: str = "orders-product",\n include_created_at: bool = False,\n) -> OpenDataContractStandard: properties = [ SchemaProperty( name="id", @@ -345,3 +345,164 @@ def _adapter_factory(platform: str, **kwargs): assert execution_adapter.apply_calls == 1 assert execution_adapter.verify_calls == 1 assert execution_adapter.metadata_calls == 1 + + + +def test_central_repo_sample_fans_out_distinct_release_artifacts( + tmp_path, + monkeypatch, + capsys, +) -> None: + """Exercise central-repo classification, release fan-out, and deployment fan-out.""" + base_root = tmp_path / "base" / "contracts" + candidate_root = tmp_path / "candidate" / "contracts" + paths = ("a/b.yaml", "a_b.yaml") + contract_ids = ("orders-nested", "orders-flat") + + for relative_path, contract_id in zip(paths, contract_ids, strict=True): + dump_yaml( + _contract(contract_id=contract_id), + base_root / relative_path, + ) + dump_yaml( + _contract(contract_id=contract_id, include_created_at=True), + candidate_root / relative_path, + ) + + execution_adapter = _ExecutionAdapter() + + def _adapter_factory(platform: str, **kwargs): + assert platform == "databricks" + if kwargs.get("execution_config") is None: + return _PreviewAdapter() + return execution_adapter + + monkeypatch.setattr( + "semapact.platforms.runtime_registry.create_deployment_adapter", + _adapter_factory, + ) + monkeypatch.setattr( + "semapact.core.config.config_manager.get", + lambda *args, **kwargs: None, + ) + + code, classification_output = _run_cli( + monkeypatch, + capsys, + "release", + "classify-repo", + "--base-root", + str(base_root), + "--candidate-root", + str(candidate_root), + "--effective-date", + "2026-09-21", + ) + assert code == 0 + classification = json.loads(classification_output) + changed = [ + item for item in classification["contracts"] if item["status"] == "changed" + ] + assert {item["contractRepoPath"] for item in changed} == set(paths) + artifact_keys = {item["artifactKey"] for item in changed} + assert len(artifact_keys) == 2 + assert all(len(key) == 64 for key in artifact_keys) + + finalized_ids: set[str] = set() + for item in changed: + relative_path = item["contractRepoPath"] + artifact_key = item["artifactKey"] + base_path = base_root / relative_path + candidate_path = candidate_root / relative_path + release_bundle = tmp_path / "release-bundles" / f"{artifact_key}.json" + release_artifact = tmp_path / "finalized" / f"{artifact_key}.json" + deployment_bundle = tmp_path / "deploy" / f"{artifact_key}.json" + + code, _ = _run_cli( + monkeypatch, + capsys, + "release", + "assess", + "--base", + str(base_path), + "--candidate", + str(candidate_path), + "--base-revision-ref", + f"git:base:{relative_path}", + "--candidate-revision-ref", + f"git:candidate:{relative_path}", + "--effective-date", + "2026-09-21", + "--bundle-out", + str(release_bundle), + ) + assert code == 0 + + code, _ = _run_cli( + monkeypatch, + capsys, + "release", + "approve", + "--bundle", + str(release_bundle), + "--actor-reference", + "github-environment:contract-release/run:central", + "--recorded-at", + "2026-09-21T04:00:00Z", + "--repository-root", + str(tmp_path), + ) + assert code == 0 + + code, finalize_output = _run_cli( + monkeypatch, + capsys, + "release", + "finalize", + "--bundle", + str(release_bundle), + "--output-contract", + str(candidate_path), + "--release-out", + str(release_artifact), + "--repository-root", + str(tmp_path), + ) + assert code == 0 + finalized = json.loads(finalize_output) + finalized_ids.add(finalized["contractReleaseId"]) + + code, _ = _run_cli( + monkeypatch, + capsys, + "deployment", + "assess", + "--release", + str(release_artifact), + "--server", + "production", + "--bundle-out", + str(deployment_bundle), + ) + assert code == 0 + + code, deploy_output = _run_cli( + monkeypatch, + capsys, + "deployment", + "deploy", + "--bundle", + str(deployment_bundle), + "--warehouse-id", + "warehouse-1", + "--output", + "json", + ) + assert code == 0 + assert json.loads(deploy_output)["status"] == "IN_SYNC" + + assert len(finalized_ids) == 2 + assert execution_adapter.preview_calls == 2 + assert execution_adapter.apply_calls == 2 + assert execution_adapter.verify_calls == 2 + assert execution_adapter.metadata_calls == 2 From 2d73e093852cf44341749990435e4732dfc6b15b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:34:02 +1000 Subject: [PATCH 476/616] test(cicd): lock collision-resistant central artifact identity --- tests/test_semapact_devops_examples_readable.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_semapact_devops_examples_readable.py b/tests/test_semapact_devops_examples_readable.py index 3387813f..6c24e9cf 100644 --- a/tests/test_semapact_devops_examples_readable.py +++ b/tests/test_semapact_devops_examples_readable.py @@ -43,6 +43,8 @@ def test_central_contract_repo_github_example_fans_out_and_commits_ledger_once() assert "release classify-repo" in workflow assert "fromJSON(needs.detect-contracts.outputs.matrix)" in workflow + assert "artifact: .artifactKey" in workflow + assert 'gsub("[^A-Za-z0-9_-]"; "_")' not in workflow assert "release assess" in workflow assert "release approve" in workflow assert "release finalize" in workflow From be83f8748db430c3d18c3cebbc1b846fad0b2953 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:34:29 +1000 Subject: [PATCH 477/616] fix(cicd): repair regression fixture contract factory --- tests/test_cicd_workflow_regression.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_cicd_workflow_regression.py b/tests/test_cicd_workflow_regression.py index d29f7d03..60be58ef 100644 --- a/tests/test_cicd_workflow_regression.py +++ b/tests/test_cicd_workflow_regression.py @@ -168,8 +168,8 @@ def _contract(\n *,\n contract_id: str = "orders-product",\n include_cr return OpenDataContractStandard( apiVersion="v3.1.0", kind="DataContract", - id="orders-product", - name="Orders", + id=contract_id, + name=contract_id, version="1.2.3", status="active", servers=[ From f0ec5d535f4b3afe47a01428f4e4aad888979724 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:34:51 +1000 Subject: [PATCH 478/616] fix(cicd): normalize regression fixture signature --- tests/test_cicd_workflow_regression.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_cicd_workflow_regression.py b/tests/test_cicd_workflow_regression.py index 60be58ef..05c52289 100644 --- a/tests/test_cicd_workflow_regression.py +++ b/tests/test_cicd_workflow_regression.py @@ -147,7 +147,11 @@ def _server(name: str, schema_name: str) -> Server: ) -def _contract(\n *,\n contract_id: str = "orders-product",\n include_created_at: bool = False,\n) -> OpenDataContractStandard: +def _contract( + *, + contract_id: str = "orders-product", + include_created_at: bool = False, +) -> OpenDataContractStandard: properties = [ SchemaProperty( name="id", From bf17c01c4e2c82384e54f3af094f67d60cc23b67 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:44:19 +1000 Subject: [PATCH 479/616] refactor(governance): remove execution date from decision --- semapact/governance/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/semapact/governance/models.py b/semapact/governance/models.py index 00fad906..b6333156 100644 --- a/semapact/governance/models.py +++ b/semapact/governance/models.py @@ -7,7 +7,6 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator -from semapact.change_context import ChangeContext from semapact.governance_codes import GovernanceReasonCode, GovernanceSeverity from semapact.lifecycle.changes import GovernanceChange from semapact.lifecycle.policy import BreakingChange @@ -77,7 +76,6 @@ class GovernanceDecision(GovernanceModel): decision_id: str decision: DecisionResult contract_id: str - context: ChangeContext breaking: bool = Field(strict=True) required_version_bump: RequiredBump validation: ValidationOutcome From 141769ef0744dcbf1036a4ca665ffa3e61110c79 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:44:23 +1000 Subject: [PATCH 480/616] refactor(governance): make decision identity date independent --- semapact/governance/evaluator.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/semapact/governance/evaluator.py b/semapact/governance/evaluator.py index a4263af1..088c9487 100644 --- a/semapact/governance/evaluator.py +++ b/semapact/governance/evaluator.py @@ -9,7 +9,6 @@ from open_data_contract_standard.model import OpenDataContractStandard -from semapact.change_context import ChangeContext from semapact.core.validator import ContractValidator from semapact.exceptions import ValidationError from semapact.governance.change_classification import ( @@ -45,7 +44,6 @@ def evaluate_governance_decision( base_contract: OpenDataContractStandard, candidate_contract: OpenDataContractStandard, *, - context: ChangeContext, merge_conflicts: Sequence[MergeConflict] = (), ) -> GovernanceDecision: """Evaluate an authoritative, deterministic governance decision for a contract change.""" @@ -92,7 +90,6 @@ def evaluate_governance_decision( decision_id = _generate_decision_id( base_contract=base_contract, candidate_contract=candidate_contract, - context=context, merge_conflicts=merge_conflicts, reasons=reasons, ) @@ -386,7 +383,6 @@ def _determine_decision( def _generate_decision_id( base_contract: OpenDataContractStandard, candidate_contract: OpenDataContractStandard, - context: ChangeContext, merge_conflicts: Sequence[MergeConflict], reasons: tuple[GovernanceReason, ...], ) -> str: @@ -402,7 +398,6 @@ def _generate_decision_id( "alg": "v1", "base": base_fp, "candidate": candidate_fp, - "effective_date": context.effective_date.isoformat(), "conflict_count": len(merge_conflicts), "conflict_paths": conflict_paths, "reason_codes": reason_codes, From 284a97726d1fed326bc9e0d13fa5f7a9858676c9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:44:36 +1000 Subject: [PATCH 481/616] refactor(contractops): remove date from changeset --- semapact/contractops/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/semapact/contractops/models.py b/semapact/contractops/models.py index 1135b1d0..18b1bb56 100644 --- a/semapact/contractops/models.py +++ b/semapact/contractops/models.py @@ -7,7 +7,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from semapact.change_context import ChangeContext from semapact.governance.gate import GovernanceOperation from semapact.lifecycle.changes import GovernanceChange from semapact.versioning import ActualVersionBump, RequiredBump @@ -39,7 +38,6 @@ class ChangeSet(ContractOpsModel): base_revision_ref: str candidate_revision_ref: str changes: tuple[GovernanceChange, ...] - context: ChangeContext source: str | None = None actor_reference: str | None = None From ef79671c0eaa427a9ca60cbc04e4961ba0c327e6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:44:40 +1000 Subject: [PATCH 482/616] refactor(contractops): make changeset date independent --- semapact/contractops/changeset.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/semapact/contractops/changeset.py b/semapact/contractops/changeset.py index 2faa7fc7..afc8a91b 100644 --- a/semapact/contractops/changeset.py +++ b/semapact/contractops/changeset.py @@ -4,7 +4,6 @@ from collections.abc import Sequence -from semapact.change_context import ChangeContext from semapact.contractops.integrity import ( SEMAPACT_CHANGESET_NAMESPACE, compute_change_set_id, @@ -20,7 +19,6 @@ def build_change_set( base_revision_ref: str, candidate_revision_ref: str, changes: Sequence[GovernanceChange], - context: ChangeContext, source: str | None = None, actor_reference: str | None = None, ) -> ChangeSet: @@ -49,7 +47,6 @@ def build_change_set( base_revision_ref=cleaned_base_ref, candidate_revision_ref=cleaned_candidate_ref, changes=canonical_changes, - context=context, source=cleaned_source, actor_reference=cleaned_actor_reference, ) @@ -60,7 +57,6 @@ def build_change_set( base_revision_ref=cleaned_base_ref, candidate_revision_ref=cleaned_candidate_ref, changes=canonical_changes, - context=context, source=cleaned_source, actor_reference=cleaned_actor_reference, ) @@ -80,7 +76,6 @@ def build_change_set_from_decision( base_revision_ref=base_revision_ref, candidate_revision_ref=candidate_revision_ref, changes=decision.changes, - context=decision.context, source=source, actor_reference=actor_reference, ) From d9e7a3e2e81ff766c480a047b1d2b665bf8b17f0 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:44:43 +1000 Subject: [PATCH 483/616] refactor(contractops): remove date from changeset identity --- semapact/contractops/integrity.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/semapact/contractops/integrity.py b/semapact/contractops/integrity.py index a9e2f3f5..794f9981 100644 --- a/semapact/contractops/integrity.py +++ b/semapact/contractops/integrity.py @@ -12,7 +12,6 @@ import uuid from collections.abc import Sequence -from semapact.change_context import ChangeContext from semapact.contractops.execution_models import ( ContractRelease, ReleaseSnapshot, @@ -75,7 +74,6 @@ def compute_change_set_id( base_revision_ref: str, candidate_revision_ref: str, changes: Sequence[GovernanceChange], - context: ChangeContext, source: str | None, actor_reference: str | None, ) -> str: @@ -84,7 +82,6 @@ def compute_change_set_id( "contract_id": contract_id, "base_revision_ref": base_revision_ref, "candidate_revision_ref": candidate_revision_ref, - "context": context.model_dump(mode="json"), "changes": [change.model_dump(mode="json") for change in canonical_changes], "source": source, "actor_reference": actor_reference, @@ -101,7 +98,6 @@ def validate_change_set_identity(change_set: ChangeSet) -> None: base_revision_ref=change_set.base_revision_ref, candidate_revision_ref=change_set.candidate_revision_ref, changes=change_set.changes, - context=change_set.context, source=change_set.source, actor_reference=change_set.actor_reference, ) From 774b674226421a68115ee3447cda14efd4b05cbf Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:02 +1000 Subject: [PATCH 484/616] fix(governance): remove final date-dependent decision field --- semapact/governance/evaluator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/semapact/governance/evaluator.py b/semapact/governance/evaluator.py index 088c9487..9356aec4 100644 --- a/semapact/governance/evaluator.py +++ b/semapact/governance/evaluator.py @@ -104,7 +104,6 @@ def evaluate_governance_decision( decision_id=decision_id, decision=decision_result, contract_id=contract_id, - context=context, breaking=bool(change_assessment.breaking_changes), required_version_bump=change_assessment.required_bump, reasons=reasons, From 14d0d8657193fbcaf462e24aae5fa8ad8d92a364 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:05 +1000 Subject: [PATCH 485/616] refactor(governance): separate mutation context from evaluation --- semapact/application/services/governance.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/semapact/application/services/governance.py b/semapact/application/services/governance.py index 12ba4cf2..b33aaad4 100644 --- a/semapact/application/services/governance.py +++ b/semapact/application/services/governance.py @@ -41,15 +41,12 @@ def evaluate( base_contract: OpenDataContractStandard, candidate_contract: OpenDataContractStandard, *, - effective_date: date | str, merge_conflicts: Sequence[MergeConflict] = (), ) -> GovernanceDecision: - """Evaluate one contract change from an application-level effective date.""" - context = self.create_context(effective_date) + """Evaluate one contract change without execution-time inputs.""" return evaluate_governance_decision( base_contract, candidate_contract, - context=context, merge_conflicts=merge_conflicts, ) @@ -58,7 +55,6 @@ def evaluate_proposal( base_contract: OpenDataContractStandard, candidate_contract: OpenDataContractStandard, *, - effective_date: date | str, base_revision_ref: str, candidate_revision_ref: str, merge_conflicts: Sequence[MergeConflict] = (), @@ -66,11 +62,9 @@ def evaluate_proposal( actor_reference: str | None = None, ) -> GovernanceProposal: """Evaluate once and project the authoritative decision into a ChangeSet.""" - context = self.create_context(effective_date) decision = evaluate_governance_decision( base_contract, candidate_contract, - context=context, merge_conflicts=merge_conflicts, ) change_set = build_change_set_from_decision( @@ -101,7 +95,6 @@ def merge_and_evaluate( decision = evaluate_governance_decision( business_contract, merge_result.contract, - context=context, merge_conflicts=merge_result.conflicts, ) return GovernanceAnalysis( From 8a501fba35c2b1a501689d5cbbfa2d4adaa1662c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:08 +1000 Subject: [PATCH 486/616] refactor(release): remove business date from planning --- semapact/application/services/release_planning.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/semapact/application/services/release_planning.py b/semapact/application/services/release_planning.py index 6fd33f58..5b1a5e5d 100644 --- a/semapact/application/services/release_planning.py +++ b/semapact/application/services/release_planning.py @@ -2,8 +2,6 @@ from __future__ import annotations -from datetime import date - from open_data_contract_standard.model import OpenDataContractStandard from semapact.application.models.release import ReleasePlanningResult @@ -29,7 +27,6 @@ def plan( base_contract: OpenDataContractStandard, candidate_contract: OpenDataContractStandard, *, - effective_date: date | str, base_revision_ref: str, candidate_revision_ref: str, authority_reference: str | None = None, @@ -38,7 +35,6 @@ def plan( proposal = self._governance.evaluate_proposal( base_contract, candidate_contract, - effective_date=effective_date, base_revision_ref=base_revision_ref, candidate_revision_ref=candidate_revision_ref, ) From 5bc5601b3f58e2e50413e4e615090c48706c0ab8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:11 +1000 Subject: [PATCH 487/616] refactor(release): make assessment date independent --- semapact/application/services/release_workflow.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/semapact/application/services/release_workflow.py b/semapact/application/services/release_workflow.py index 530bdeea..ea7d61e9 100644 --- a/semapact/application/services/release_workflow.py +++ b/semapact/application/services/release_workflow.py @@ -39,7 +39,6 @@ def assess( base_contract: OpenDataContractStandard, candidate_contract: OpenDataContractStandard, *, - effective_date: str, base_revision_ref: str, candidate_revision_ref: str, authority_reference: str | None = None, @@ -48,7 +47,6 @@ def assess( planning = self._planning.plan( base_contract, candidate_contract, - effective_date=effective_date, base_revision_ref=base_revision_ref, candidate_revision_ref=candidate_revision_ref, authority_reference=authority_reference, From 64c7736ae978ccbb57aec053d376b4d4b6037aa4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:16 +1000 Subject: [PATCH 488/616] refactor(release): make repository classification date independent --- semapact/application/services/repository_classification.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/semapact/application/services/repository_classification.py b/semapact/application/services/repository_classification.py index 5ae6cd9a..65a80586 100644 --- a/semapact/application/services/repository_classification.py +++ b/semapact/application/services/repository_classification.py @@ -7,7 +7,6 @@ from pathlib import Path from typing import Any -from semapact.change_context import ChangeContext from semapact.governance import DecisionResult, GovernanceDecision, evaluate_governance_decision from semapact.utils.schema_utils import contract_to_model from semapact.utils.yaml_utils import list_yaml_documents, load_yaml @@ -33,7 +32,6 @@ def classify_contracts_in_repo( *, base_root: str | Path, candidate_root: str | Path, - context: ChangeContext, ) -> list[RepositoryContractChange]: """Compare two contract roots using the canonical governance evaluator.""" base_root_path = Path(base_root).expanduser().resolve() @@ -78,7 +76,7 @@ def classify_contracts_in_repo( base = contract_to_model(load_yaml(base_path)) candidate = contract_to_model(load_yaml(candidate_path)) - decision = evaluate_governance_decision(base, candidate, context=context) + decision = evaluate_governance_decision(base, candidate) status = ( "blocked" if decision.decision is DecisionResult.BLOCK From 9311520243279dc9c743134f009f8e261a484f22 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:27 +1000 Subject: [PATCH 489/616] refactor(deployment): remove business date from assessment --- semapact/application/services/deployment_workflow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py index 40016083..5abe20d8 100644 --- a/semapact/application/services/deployment_workflow.py +++ b/semapact/application/services/deployment_workflow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date, datetime, timezone +from datetime import datetime, timezone from typing import Literal from open_data_contract_standard.model import OpenDataContractStandard @@ -49,7 +49,6 @@ def assess( base_contract: OpenDataContractStandard, candidate_contract: OpenDataContractStandard, *, - effective_date: date | str, base_revision_ref: str, candidate_revision_ref: str, target: DeploymentTarget, @@ -59,7 +58,6 @@ def assess( proposal = self._governance.evaluate_proposal( base_contract, candidate_contract, - effective_date=effective_date, base_revision_ref=base_revision_ref, candidate_revision_ref=candidate_revision_ref, ) From 23b1281b267ed40faada63bea18a3a2eef014c7d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:32 +1000 Subject: [PATCH 490/616] refactor(cli): remove release effective date plumbing --- semapact/interfaces/commands/release_cmd.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index 54c841df..fc146a61 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -23,7 +23,6 @@ def run_release_assess(args: argparse.Namespace) -> dict[str, Any]: bundle = ReleaseWorkflowService().assess( base_contract, candidate_contract, - effective_date=args.effective_date, base_revision_ref=args.base_revision_ref, candidate_revision_ref=args.candidate_revision_ref, authority_reference=args.authority_reference, @@ -130,7 +129,6 @@ def run_release_classify(args: argparse.Namespace) -> dict[str, Any]: decision = GovernanceService().evaluate( base_contract, candidate_contract, - effective_date=args.effective_date, ) evaluate_governance_gate(decision, GovernanceOperation.ANALYZE) @@ -188,11 +186,9 @@ def run_release_classify_repo(args: argparse.Namespace) -> dict[str, Any]: repository_change_to_dict, ) - change_context = GovernanceService.create_context(args.effective_date) results = classify_contracts_in_repo( base_root=args.base_root, candidate_root=args.candidate_root, - context=change_context, ) return {"contracts": [repository_change_to_dict(item) for item in results]} From f8d7f64eaf5122c5fdc62d04a0130cb554107b38 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:35 +1000 Subject: [PATCH 491/616] refactor(cli): remove deployment assessment effective date --- semapact/interfaces/commands/deployment_cmd.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 2c0c1d79..3f456475 100644 --- a/semapact/interfaces/commands/deployment_cmd.py +++ b/semapact/interfaces/commands/deployment_cmd.py @@ -111,7 +111,6 @@ def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: bundle = service.assess( base_contract, candidate_contract, - effective_date=args.effective_date, base_revision_ref=args.base_revision_ref, candidate_revision_ref=args.candidate_revision_ref, target=target, @@ -190,7 +189,6 @@ def _require_candidate_args(args: argparse.Namespace) -> None: "--candidate": args.candidate, "--base-revision-ref": args.base_revision_ref, "--candidate-revision-ref": args.candidate_revision_ref, - "--effective-date": args.effective_date, } missing = [name for name, value in required.items() if not value] if missing: @@ -207,7 +205,6 @@ def _reject_candidate_args_for_release(args: argparse.Namespace) -> None: ("--candidate", args.candidate), ("--base-revision-ref", args.base_revision_ref), ("--candidate-revision-ref", args.candidate_revision_ref), - ("--effective-date", args.effective_date), ) if value ] From a19364ba739dd94df0501aca236bb86bd0a42e3a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:38 +1000 Subject: [PATCH 492/616] refactor(cli): keep effective date only on mutation commands --- semapact/interfaces/cli.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index 013e409c..a5750e17 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -292,7 +292,6 @@ def _build_parser() -> argparse.ArgumentParser: "--bundle-out", help="Write the immutable ReleaseBundle JSON artifact to this path", ) - _add_effective_date_argument(release_assess_parser) release_approve_parser = release_subparsers.add_parser( "approve", @@ -343,7 +342,6 @@ def _build_parser() -> argparse.ArgumentParser: release_classify_parser.add_argument("--base", required=True) release_classify_parser.add_argument("--candidate", required=True) release_classify_parser.add_argument("--runtime-context", default="auto") - _add_effective_date_argument(release_classify_parser) release_plan_parser = release_subparsers.add_parser( "plan", @@ -358,7 +356,6 @@ def _build_parser() -> argparse.ArgumentParser: help="Explicit Git release reference when release.versionAuthority=git", ) release_plan_parser.add_argument("--runtime-context", default="auto") - _add_effective_date_argument(release_plan_parser) release_classify_repo_parser = release_subparsers.add_parser( "classify-repo", @@ -366,7 +363,6 @@ def _build_parser() -> argparse.ArgumentParser: ) release_classify_repo_parser.add_argument("--base-root", required=True) release_classify_repo_parser.add_argument("--candidate-root", required=True) - _add_effective_date_argument(release_classify_repo_parser) doctor_parser = subparsers.add_parser( @@ -490,7 +486,6 @@ def _build_parser() -> argparse.ArgumentParser: default="text", help="Output format (default: text)", ) - _add_effective_date_argument(deployment_assess_parser, required=False) deployment_deploy_parser = deployment_subparsers.add_parser( "deploy", From fb121849982d4886e8f4fc777e3a2777ffb3ffcb Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:48 +1000 Subject: [PATCH 493/616] refactor(cicd): remove execution date from assessment --- examples/github/data-product-ci-cd.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/github/data-product-ci-cd.yml b/examples/github/data-product-ci-cd.yml index fc26e879..672f9f7a 100644 --- a/examples/github/data-product-ci-cd.yml +++ b/examples/github/data-product-ci-cd.yml @@ -60,7 +60,6 @@ jobs: --candidate "${CONTRACT_PATH}" \ --base-revision-ref "git:${BASE_SHA}" \ --candidate-revision-ref "git:${CANDIDATE_SHA}" \ - --effective-date "$(date -u +%F)" \ --server "${DEV_SERVER}" \ --bundle-out artifacts/candidate.deployment.bundle.json \ --output json > artifacts/candidate.summary.json @@ -108,7 +107,6 @@ jobs: --candidate "${CONTRACT_PATH}" \ --base-revision-ref "git:${BASE_SHA}" \ --candidate-revision-ref "git:${CANDIDATE_SHA}" \ - --effective-date "$(date -u +%F)" \ --bundle-out artifacts/release.bundle.json \ > artifacts/release.summary.json From 91f4d3a6d6797b7b8b1ac5eef72b46e9f8096203 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:52 +1000 Subject: [PATCH 494/616] refactor(cicd): remove execution date from assessment --- examples/github/central-contract-repo-ci-cd.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/github/central-contract-repo-ci-cd.yml b/examples/github/central-contract-repo-ci-cd.yml index deb8c1a1..cd1cf1d8 100644 --- a/examples/github/central-contract-repo-ci-cd.yml +++ b/examples/github/central-contract-repo-ci-cd.yml @@ -66,7 +66,6 @@ jobs: semapact release classify-repo \ --base-root "/tmp/semapact-base/${CONTRACT_ROOT}" \ --candidate-root "${CONTRACT_ROOT}" \ - --effective-date "$(date -u +%F)" \ > artifacts/repository-changes.json cat artifacts/repository-changes.json @@ -142,7 +141,6 @@ jobs: --candidate "${CONTRACT_ROOT}/${CONTRACT_PATH}" \ --base-revision-ref "git:${BASE_SHA}" \ --candidate-revision-ref "git:${CANDIDATE_SHA}" \ - --effective-date "$(date -u +%F)" \ --server "${DEV_SERVER}" \ --bundle-out "artifacts/${ARTIFACT}.candidate.deployment.bundle.json" @@ -193,7 +191,6 @@ jobs: --candidate "${CONTRACT_ROOT}/${CONTRACT_PATH}" \ --base-revision-ref "git:${BASE_SHA}" \ --candidate-revision-ref "git:${CANDIDATE_SHA}" \ - --effective-date "$(date -u +%F)" \ --bundle-out "artifacts/${ARTIFACT}.release.bundle.json" - name: Upload exact release bundle From 3038fdf781074b088505b6d6f6d02e110ace743c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:55 +1000 Subject: [PATCH 495/616] test(cicd): use date-independent assess commands --- tests/test_cicd_workflow_regression.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_cicd_workflow_regression.py b/tests/test_cicd_workflow_regression.py index 05c52289..4c9a43de 100644 --- a/tests/test_cicd_workflow_regression.py +++ b/tests/test_cicd_workflow_regression.py @@ -239,8 +239,6 @@ def _adapter_factory(platform: str, **kwargs): "git:base", "--candidate-revision-ref", "git:candidate", - "--effective-date", - "2026-09-21", "--server", "development", "--bundle-out", @@ -265,8 +263,6 @@ def _adapter_factory(platform: str, **kwargs): "git:base", "--candidate-revision-ref", "git:candidate", - "--effective-date", - "2026-09-21", "--bundle-out", str(release_bundle), ) @@ -399,8 +395,6 @@ def _adapter_factory(platform: str, **kwargs): str(base_root), "--candidate-root", str(candidate_root), - "--effective-date", - "2026-09-21", ) assert code == 0 classification = json.loads(classification_output) From 90afc54546d3f973bb3c81dce05c11705765d8d6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:45:58 +1000 Subject: [PATCH 496/616] test(deployment): remove assessment date surface --- tests/interfaces/test_deployment_cmd.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index 0d0918bc..b274741e 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -30,8 +30,6 @@ def test_deployment_parser_exposes_candidate_and_finalized_release_modes() -> No "git:base", "--candidate-revision-ref", "git:candidate", - "--effective-date", - "2026-09-20", "--server", "development", "--bundle-out", @@ -143,7 +141,6 @@ def test_candidate_assessment_requires_candidate_arguments() -> None: "candidate": None, "base_revision_ref": None, "candidate_revision_ref": None, - "effective_date": None, }, )() @@ -160,7 +157,6 @@ def test_release_assessment_rejects_candidate_arguments() -> None: "candidate": None, "base_revision_ref": None, "candidate_revision_ref": None, - "effective_date": None, }, )() From 5289f781ccb94d0bf9951068cfc9f59118eeb6fc Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:46:00 +1000 Subject: [PATCH 497/616] test(cli): remove date from release analysis commands --- tests/test_semapact_cli_readable.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_semapact_cli_readable.py b/tests/test_semapact_cli_readable.py index 189306f3..2d1549a8 100644 --- a/tests/test_semapact_cli_readable.py +++ b/tests/test_semapact_cli_readable.py @@ -321,8 +321,6 @@ def test_cli_release_classify_outputs_per_contract_required_bump( str(base_path), "--candidate", str(candidate_path), - "--effective-date", - TEST_EFFECTIVE_DATE, ], ) @@ -362,8 +360,6 @@ def test_cli_release_classify_repo_outputs_per_contract_results( str(base_root), "--candidate-root", str(candidate_root), - "--effective-date", - TEST_EFFECTIVE_DATE, ], ) From 086d33f3991f2af952dcc0d164121d8f471f5229 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:47:37 +1000 Subject: [PATCH 498/616] refactor(governance): remove effective date from public decision protocol --- semapact/governance/public.py | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/semapact/governance/public.py b/semapact/governance/public.py index 13e7fbf3..c84c3f8a 100644 --- a/semapact/governance/public.py +++ b/semapact/governance/public.py @@ -169,15 +169,6 @@ class PublicGovernanceModel(BaseModel): ) -class PublicChangeContextV1(PublicGovernanceModel): - """Public representation of contextual evaluation parameters.""" - - effective_date: str = Field( - validation_alias=AliasChoices("effective_date", "effectiveDate"), - serialization_alias="effectiveDate", - ) - - class PublicGovernanceReasonV1(PublicGovernanceModel): """Public structured reason for a governance outcome or violation.""" @@ -282,7 +273,6 @@ class PublicGovernanceDecisionV1(PublicGovernanceModel): validation_alias=AliasChoices("contract_id", "contractId"), serialization_alias="contractId", ) - context: PublicChangeContextV1 breaking: bool required_version_bump: PublicRequiredVersionBump = Field( validation_alias=AliasChoices("required_version_bump", "requiredVersionBump"), @@ -379,22 +369,17 @@ def to_public_governance_decision(decision: GovernanceDecision) -> PublicGoverna f"to_public_governance_decision requires GovernanceDecision, got {type(decision).__name__}" ) - # 1. Project context - context = PublicChangeContextV1( - effective_date=decision.context.effective_date.isoformat() - ) - - # 2. Project reasons + # 1. Project reasons projected_reasons = tuple(_project_reason(r) for r in decision.reasons) - # 3. Project validation outcome + # 2. Project validation outcome validation_issues = tuple(_project_reason(r) for r in decision.validation.issues) validation = PublicValidationOutcomeV1( valid=decision.validation.valid, issues=validation_issues, ) - # 4. Project policy outcome (omits internal BreakingChange structs) + # 3. Project policy outcome (omits internal BreakingChange structs) policy_violations = tuple(_project_reason(r) for r in decision.policy.violations) policy = PublicPolicyOutcomeV1( valid=decision.policy.valid, @@ -404,16 +389,16 @@ def to_public_governance_decision(decision: GovernanceDecision) -> PublicGoverna violations=policy_violations, ) - # 5. Project evidence + # 4. Project evidence evidence = PublicChangeEvidenceV1( has_changes=decision.evidence.has_changes, merge_conflicts_count=decision.evidence.merge_conflicts_count, ) - # 6. Project canonical changes + # 5. Project canonical changes projected_changes = tuple(_project_change(c) for c in decision.changes) - # 7. Aggregate stable unique reason codes in deterministic alphabetical order + # 6. Aggregate stable unique reason codes in deterministic alphabetical order all_reason_codes: set[str] = set() for r in projected_reasons: all_reason_codes.add(r.code) @@ -428,7 +413,6 @@ def to_public_governance_decision(decision: GovernanceDecision) -> PublicGoverna decision_id=decision.decision_id, decision=decision_val, contract_id=decision.contract_id, - context=context, breaking=decision.breaking, required_version_bump=bump_val, reason_codes=tuple(sorted(all_reason_codes)), From 49dfb40a6f286c5087d309b8c9bb22abf1b3fffe Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:47:42 +1000 Subject: [PATCH 499/616] refactor(governance): stop exporting lifecycle mutation context --- semapact/governance/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/semapact/governance/__init__.py b/semapact/governance/__init__.py index bd9be777..a6499272 100644 --- a/semapact/governance/__init__.py +++ b/semapact/governance/__init__.py @@ -1,6 +1,5 @@ """SemaPact Governance Kernel package.""" -from semapact.change_context import ChangeContext from semapact.governance_codes import ( GOVERNANCE_REASON_REGISTRY, GovernanceReasonCode, @@ -24,7 +23,6 @@ evaluate_governance_gate, ) from semapact.governance.public import ( - PublicChangeContextV1, PublicChangeDomain, PublicChangeEvidenceV1, PublicChangeType, @@ -45,7 +43,6 @@ ) __all__ = [ - "ChangeContext", "DecisionResult", "GovernanceReasonCode", "GovernanceSeverity", @@ -70,7 +67,6 @@ "PublicEntityType", "PublicChangeDomain", "PublicEvidenceSource", - "PublicChangeContextV1", "PublicGovernanceReasonV1", "PublicValidationOutcomeV1", "PublicPolicyOutcomeV1", From 901aa278857904bce8f116928519dec53527576d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:47:45 +1000 Subject: [PATCH 500/616] refactor(contractops): remove date context coupling --- semapact/contractops/context.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/semapact/contractops/context.py b/semapact/contractops/context.py index 37df8ec2..1a3f90ee 100644 --- a/semapact/contractops/context.py +++ b/semapact/contractops/context.py @@ -29,10 +29,6 @@ def validate_proposal_context( raise ReleaseValidationError( "ChangeSet and GovernanceDecision contract IDs do not match" ) - if change_set.context != decision.context: - raise ReleaseValidationError( - "ChangeSet and GovernanceDecision governance contexts do not match" - ) if change_set.changes != decision.changes: raise ReleaseValidationError( "ChangeSet changes do not match authoritative GovernanceDecision changes" From 3ffaa852397a2894905bbaafe4e3472a36816768 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:48:09 +1000 Subject: [PATCH 501/616] refactor(lifecycle): scope effective date to mutations --- semapact/change_context.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/semapact/change_context.py b/semapact/change_context.py index 3bf95336..6f72d568 100644 --- a/semapact/change_context.py +++ b/semapact/change_context.py @@ -1,4 +1,4 @@ -"""Explicit contextual inputs for deterministic contract change analysis.""" +"""Explicit business-effective context for lifecycle mutations.""" from __future__ import annotations @@ -8,12 +8,12 @@ class ChangeContext(BaseModel): - """Governance-relevant context supplied explicitly by the caller. + """Business-effective inputs used when a mutation materializes dated state. - ``effective_date`` is required and intentionally has no wall-clock default. - Callers must create the context at an upstream workflow boundary and pass the - same instance through merge and governance evaluation. Lower layers must not - regenerate or overwrite this date. + The context is deliberately explicit and has no wall-clock default. It belongs at + lifecycle/merge mutation boundaries where SemaPact may write facts such as + `deprecationDate`. Pure governance, release planning, and deployment assessment + must not depend on it. """ model_config = ConfigDict(frozen=True, extra="forbid") From 883693f263ceef6f0bf6c89a8bc692e03933df18 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:49:16 +1000 Subject: [PATCH 502/616] refactor(lifecycle): keep date only for mutation --- semapact/core/lifecycle_cli.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/semapact/core/lifecycle_cli.py b/semapact/core/lifecycle_cli.py index d126c3b4..30478c9e 100644 --- a/semapact/core/lifecycle_cli.py +++ b/semapact/core/lifecycle_cli.py @@ -7,8 +7,8 @@ ) from semapact.core.loader import ContractLoader +from semapact.change_context import ChangeContext from semapact.governance import ( - ChangeContext, GovernanceOperation, enforce_governance_gate, evaluate_governance_decision, @@ -40,8 +40,7 @@ def apply_lifecycle( args.schema, args.property, target_status, - context=context, - ) + ) elif getattr(args, "schema", None): _apply_schema( candidate_contract, From 62d23a088326347573d2ec1b6c7849a8a19e73a7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:49:20 +1000 Subject: [PATCH 503/616] refactor(pipeline): separate merge date from governance identity --- semapact/orchestrator/pipeline.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/semapact/orchestrator/pipeline.py b/semapact/orchestrator/pipeline.py index 8e6b1866..aa97f80e 100644 --- a/semapact/orchestrator/pipeline.py +++ b/semapact/orchestrator/pipeline.py @@ -25,8 +25,8 @@ from datacontract.data_contract import DataContract +from semapact.change_context import ChangeContext from semapact.governance import ( - ChangeContext, GovernanceDecision, GovernanceOperation, enforce_governance_gate, @@ -293,12 +293,10 @@ def run( if merged_contract is None: raise ValueError("Merge did not produce a contract") - # Single-pass governance decision evaluation using the same explicit - # context as merge. + # Governance evaluation is independent of the mutation effective date. decision = evaluate_governance_decision( business_contract, merged_contract, - context=change_context, merge_conflicts=conflicts, ) From 1e49bfb39914f6acf09a3777f317297165253c02 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:49:24 +1000 Subject: [PATCH 504/616] refactor(plan): keep date only in in-memory merge --- semapact/interfaces/commands/plan_cmd.py | 1 - 1 file changed, 1 deletion(-) diff --git a/semapact/interfaces/commands/plan_cmd.py b/semapact/interfaces/commands/plan_cmd.py index 3e3972a6..b2f8a4d6 100644 --- a/semapact/interfaces/commands/plan_cmd.py +++ b/semapact/interfaces/commands/plan_cmd.py @@ -83,7 +83,6 @@ def run_plan(args: argparse.Namespace) -> None: decision = evaluate_governance_decision( base_contract, merged, - context=change_context, merge_conflicts=merge_result.conflicts, ) gate_res = evaluate_governance_gate(decision, GovernanceOperation.ANALYZE) From 2d355791709f5c64338655ff983188ab7d27578a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:49:28 +1000 Subject: [PATCH 505/616] refactor(history): remove date coupling from proposal links --- semapact/application/services/history.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/semapact/application/services/history.py b/semapact/application/services/history.py index b1ab1d0d..4d48a1c7 100644 --- a/semapact/application/services/history.py +++ b/semapact/application/services/history.py @@ -96,7 +96,5 @@ def _validate_proposal_links( "ChangeSet candidate_revision_ref must equal candidate ContractRevision ID" ) - if change_set.context != decision.context: - raise ValueError("ChangeSet context does not match GovernanceDecision context") if change_set.changes != decision.changes: raise ValueError("ChangeSet changes do not match GovernanceDecision changes") From aee0ee0ffb33e29812ec485efadaf762acb607e5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:49:54 +1000 Subject: [PATCH 506/616] test(release): remove assessment date --- tests/interfaces/test_release_workflow_cmd.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/interfaces/test_release_workflow_cmd.py b/tests/interfaces/test_release_workflow_cmd.py index 76e628cb..4e1036e4 100644 --- a/tests/interfaces/test_release_workflow_cmd.py +++ b/tests/interfaces/test_release_workflow_cmd.py @@ -61,7 +61,6 @@ def test_release_finalize_materializes_selected_version_and_history(tmp_path) -> candidate=str(candidate), base_revision_ref="git:base", candidate_revision_ref="git:candidate", - effective_date="2026-09-20", authority_reference=None, runtime_context="auto", bundle_out=str(bundle), From 2d1c55e4795c9969d03b3f86807f36cb8b90face Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:49:56 +1000 Subject: [PATCH 507/616] test(deployment): make workflow assessment date independent --- tests/test_deployment_workflow_service.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 090b023b..41d29176 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -207,7 +207,6 @@ def _finalized_release(): bundle = workflow.assess( _contract(name="Orders"), _contract(name="Orders", include_created_at=True), - effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", ) @@ -226,7 +225,6 @@ def test_candidate_assessment_does_not_calculate_release_version() -> None: bundle = DeploymentWorkflowService().assess( _contract(name="Orders old"), _contract(name="Orders new"), - effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", target=_target(), @@ -262,7 +260,6 @@ def test_finalized_release_assessment_binds_exact_release_record(tmp_path) -> No def test_same_candidate_inputs_produce_same_bundle_digest() -> None: service = DeploymentWorkflowService() kwargs = dict( - effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", target=_target(), @@ -287,7 +284,6 @@ def test_bundle_rehydration_fails_closed_when_digest_is_tampered() -> None: bundle = DeploymentWorkflowService().assess( _contract(name="Orders old"), _contract(name="Orders new"), - effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", target=_target(), @@ -305,7 +301,6 @@ def test_candidate_review_deployment_needs_no_release_approval() -> None: bundle = service.assess( _contract(name="Orders"), _contract(name="Orders", include_created_at=True), - effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", target=_target(), @@ -325,7 +320,6 @@ def test_cd_uses_fresh_preview_not_ci_review_preview() -> None: bundle = service.assess( _contract(name="Orders old"), _contract(name="Orders new"), - effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", target=_target(), @@ -348,7 +342,6 @@ def test_configured_operational_history_records_failed_candidate_deployment() -> bundle = service.assess( _contract(name="Orders old"), _contract(name="Orders new"), - effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", target=_target(), From 7d6ab4e9d0ac1e3292126e91e7c87f000583d59d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:50:00 +1000 Subject: [PATCH 508/616] test(release): make planning date independent --- tests/test_release_planning_service.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_release_planning_service.py b/tests/test_release_planning_service.py index d8d59282..296cdb83 100644 --- a/tests/test_release_planning_service.py +++ b/tests/test_release_planning_service.py @@ -70,7 +70,6 @@ def test_release_planning_composes_one_decision_into_exact_m2_artifacts() -> Non result = service.plan( _contract(name="Orders old"), _contract(name="Orders new"), - effective_date="2026-09-11", base_revision_ref="git:base-123", candidate_revision_ref="git:candidate-456", ) @@ -99,14 +98,12 @@ def test_release_planning_is_deterministic_for_same_exact_inputs() -> None: first = service.plan( base, candidate, - effective_date="2026-09-11", base_revision_ref="git:base-123", candidate_revision_ref="git:candidate-456", ) second = service.plan( base, candidate, - effective_date="2026-09-11", base_revision_ref="git:base-123", candidate_revision_ref="git:candidate-456", ) From 11c69c08f52d37b9c5e38790d3dd3b925681f93b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:50:03 +1000 Subject: [PATCH 509/616] test(release): make formal assessment date independent --- tests/test_release_workflow_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_release_workflow_service.py b/tests/test_release_workflow_service.py index 94f54b41..21127183 100644 --- a/tests/test_release_workflow_service.py +++ b/tests/test_release_workflow_service.py @@ -51,7 +51,6 @@ def _bundle() -> ReleaseBundle: return ReleaseWorkflowService().assess( _contract(), _contract(include_created_at=True), - effective_date="2026-09-20", base_revision_ref="git:base", candidate_revision_ref="git:candidate", ) From bac6b4fb130de1a510a323d6c7bf3c06439c9642 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:50:20 +1000 Subject: [PATCH 510/616] test(release): remove effective date from public release CLI --- tests/interfaces/test_release_plan_cmd.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/interfaces/test_release_plan_cmd.py b/tests/interfaces/test_release_plan_cmd.py index cbaeea91..a9a18ffc 100644 --- a/tests/interfaces/test_release_plan_cmd.py +++ b/tests/interfaces/test_release_plan_cmd.py @@ -22,8 +22,6 @@ def test_release_plan_parser_requires_explicit_revision_refs() -> None: "git:base-123", "--candidate-revision-ref", "git:candidate-456", - "--effective-date", - "2026-09-11", ] ) @@ -49,8 +47,6 @@ def test_release_plan_parser_accepts_git_authority_reference() -> None: "git:candidate", "--authority-reference", "v2.0.0", - "--effective-date", - "2026-09-11", ] ) @@ -83,8 +79,6 @@ def test_main_routes_release_plan_to_release_command_adapter( "git:base", "--candidate-revision-ref", "git:candidate", - "--effective-date", - "2026-09-11", ], ) @@ -120,8 +114,6 @@ def test_release_parser_exposes_assess_approve_finalize() -> None: "git:base", "--candidate-revision-ref", "git:candidate", - "--effective-date", - "2026-09-20", "--bundle-out", "release.bundle.json", ] @@ -178,8 +170,6 @@ def test_main_routes_release_assess_to_release_command_adapter( "git:base", "--candidate-revision-ref", "git:candidate", - "--effective-date", - "2026-09-20", ], ) From edbcb9be3ff275953fe822197538d8f988f07c48 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:50:54 +1000 Subject: [PATCH 511/616] test(governance): separate mutation date from decision --- tests/test_governance_service.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/test_governance_service.py b/tests/test_governance_service.py index 7638ad04..0ecc1fd7 100644 --- a/tests/test_governance_service.py +++ b/tests/test_governance_service.py @@ -62,14 +62,10 @@ def _custom_property_value(entity: object, key: str) -> str | None: return None -def test_governance_service_constructs_context_from_request_value() -> None: - decision = GovernanceService().evaluate( - _contract(), - _contract(), - effective_date="2026-08-13", - ) +def test_governance_service_evaluation_has_no_business_date_context() -> None: + decision = GovernanceService().evaluate(_contract(), _contract()) - assert decision.context.effective_date == date(2026, 8, 13) + assert not hasattr(decision, "context") def test_governance_service_reuses_context_for_merge_and_evaluation() -> None: @@ -88,8 +84,8 @@ def test_governance_service_reuses_context_for_merge_and_evaluation() -> None: if prop.name == "legacy_col" ) - assert analysis.decision.context == analysis.context assert analysis.context.effective_date == date(2026, 8, 13) + assert not hasattr(analysis.decision, "context") assert _custom_property_value(merged_property, "deprecationDate") == "2026-08-13" From 5bf2c10aa0b9ea7a7d984b05e3204b676a7d16aa Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:50:56 +1000 Subject: [PATCH 512/616] test(governance): proposal evaluation is date independent --- tests/test_governance_proposal.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/test_governance_proposal.py b/tests/test_governance_proposal.py index 90ab2690..7b1f9fb7 100644 --- a/tests/test_governance_proposal.py +++ b/tests/test_governance_proposal.py @@ -11,7 +11,6 @@ import semapact.application.services.governance as governance_service_module from semapact.application.services.governance import GovernanceService -from semapact.change_context import ChangeContext from semapact.governance.models import GovernanceDecision from semapact.lifecycle.merge_engine import MergeConflict @@ -51,7 +50,6 @@ def counted_evaluator( base_contract: OpenDataContractStandard, candidate_contract: OpenDataContractStandard, *, - context: ChangeContext, merge_conflicts: Sequence[MergeConflict] = (), ) -> GovernanceDecision: nonlocal calls @@ -59,7 +57,6 @@ def counted_evaluator( return original( base_contract, candidate_contract, - context=context, merge_conflicts=merge_conflicts, ) @@ -72,7 +69,6 @@ def counted_evaluator( proposal = GovernanceService().evaluate_proposal( base, candidate, - effective_date="2026-09-09", base_revision_ref="git:abc123", candidate_revision_ref="git:def456", source="api", @@ -81,7 +77,8 @@ def counted_evaluator( assert calls == 1 assert proposal.change_set.changes == proposal.decision.changes - assert proposal.change_set.context == proposal.decision.context + assert not hasattr(proposal.change_set, "context") + assert not hasattr(proposal.decision, "context") assert proposal.change_set.base_revision_ref == "git:abc123" assert proposal.change_set.candidate_revision_ref == "git:def456" assert proposal.change_set.source == "api" From 159317290f13d9f074d244e79a4007edf8c41537 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:51:00 +1000 Subject: [PATCH 513/616] test(release): remove date context from proposal artifacts --- tests/test_release_plan.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/test_release_plan.py b/tests/test_release_plan.py index 4c0b943d..4a0f9959 100644 --- a/tests/test_release_plan.py +++ b/tests/test_release_plan.py @@ -1,7 +1,5 @@ from __future__ import annotations -from datetime import date - import pytest from open_data_contract_standard.model import ( OpenDataContractStandard, @@ -10,7 +8,6 @@ ) from pydantic import ValidationError as PydanticValidationError -from semapact.change_context import ChangeContext from semapact.contractops import ( ReleasePrecondition, build_change_set_from_decision, @@ -20,9 +17,6 @@ from semapact.governance import DecisionResult, evaluate_governance_decision -CONTEXT = ChangeContext(effective_date=date(2026, 9, 9)) - - def _contract( *, contract_id: str = "orders-product", @@ -64,7 +58,7 @@ def _proposal( base_revision_ref: str = "rev:base", candidate_revision_ref: str = "rev:candidate", ): - decision = evaluate_governance_decision(base, candidate, context=CONTEXT) + decision = evaluate_governance_decision(base, candidate) change_set = build_change_set_from_decision( decision, base_revision_ref=base_revision_ref, @@ -146,7 +140,6 @@ def test_release_plan_fails_closed_for_mismatched_proposal_artifacts() -> None: review_change_set, review_decision = _proposal(base, review_candidate) metadata_change_set, _ = _proposal(base, metadata_candidate) - assert review_change_set.context == metadata_change_set.context assert review_change_set.contract_id == metadata_change_set.contract_id assert review_change_set.changes != metadata_change_set.changes From 5aaea435764678393dcde2c4cdf036c38038d355 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:51:12 +1000 Subject: [PATCH 514/616] test(contractops): make changeset identity date independent --- tests/test_change_set.py | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/tests/test_change_set.py b/tests/test_change_set.py index 87c1fd5a..904fcf0f 100644 --- a/tests/test_change_set.py +++ b/tests/test_change_set.py @@ -1,7 +1,5 @@ from __future__ import annotations -from datetime import date - import pytest from open_data_contract_standard.model import ( OpenDataContractStandard, @@ -10,7 +8,6 @@ ) from pydantic import ValidationError as PydanticValidationError -from semapact.change_context import ChangeContext from semapact.contractops import build_change_set, build_change_set_from_decision from semapact.governance import evaluate_governance_decision from semapact.lifecycle.changes import ( @@ -58,7 +55,6 @@ def _contract(physical_type: str) -> OpenDataContractStandard: def test_changeset_identity_is_deterministic_and_change_order_independent() -> None: - context = ChangeContext(effective_date=date(2026, 9, 9)) id_change = _change("id", before="STRING", after="BIGINT") amount_change = _change("amount", before="DECIMAL(10,2)", after="DECIMAL(18,2)") @@ -67,14 +63,12 @@ def test_changeset_identity_is_deterministic_and_change_order_independent() -> N base_revision_ref="git:abc123", candidate_revision_ref="git:def456", changes=(id_change, amount_change), - context=context, ) second = build_change_set( contract_id="orders-product", base_revision_ref="git:abc123", candidate_revision_ref="git:def456", changes=(amount_change, id_change), - context=context, ) assert first == second @@ -83,7 +77,7 @@ def test_changeset_identity_is_deterministic_and_change_order_independent() -> N assert first.model_dump(mode="json") == second.model_dump(mode="json") -def test_changeset_identity_changes_with_revision_or_governance_context() -> None: +def test_changeset_identity_changes_with_exact_revision() -> None: change = _change("id", before="STRING", after="BIGINT") base_args = { "contract_id": "orders-product", @@ -94,31 +88,21 @@ def test_changeset_identity_changes_with_revision_or_governance_context() -> Non first = build_change_set( **base_args, - context=ChangeContext(effective_date=date(2026, 9, 9)), ) different_candidate = build_change_set( **{**base_args, "candidate_revision_ref": "git:ghi789"}, - context=ChangeContext(effective_date=date(2026, 9, 9)), - ) - different_context = build_change_set( - **base_args, - context=ChangeContext(effective_date=date(2026, 9, 10)), ) - assert first.change_set_id != different_candidate.change_set_id - assert first.change_set_id != different_context.change_set_id def test_changeset_identity_covers_provenance_without_affecting_governance() -> None: change = _change("id", before="STRING", after="BIGINT") - context = ChangeContext(effective_date=date(2026, 9, 9)) first = build_change_set( contract_id="orders-product", base_revision_ref="git:abc123", candidate_revision_ref="git:def456", changes=(change,), - context=context, source="cli", actor_reference="user:alice", ) @@ -127,14 +111,12 @@ def test_changeset_identity_covers_provenance_without_affecting_governance() -> base_revision_ref="git:abc123", candidate_revision_ref="git:def456", changes=(change,), - context=context, source="api", actor_reference="service:ci", ) assert first.change_set_id != second.change_set_id assert first.changes == second.changes - assert first.context == second.context assert first.source == "cli" assert second.source == "api" assert first.actor_reference == "user:alice" @@ -147,18 +129,16 @@ def test_changeset_is_immutable() -> None: base_revision_ref="git:abc123", candidate_revision_ref="git:def456", changes=(), - context=ChangeContext(effective_date=date(2026, 9, 9)), ) with pytest.raises(PydanticValidationError): change_set.contract_id = "other" # type: ignore[misc] -def test_changeset_from_decision_reuses_authoritative_changes_and_context() -> None: +def test_changeset_from_decision_reuses_authoritative_changes_without_date_context() -> None: base = _contract("STRING") candidate = _contract("BIGINT") - context = ChangeContext(effective_date=date(2026, 9, 9)) - decision = evaluate_governance_decision(base, candidate, context=context) + decision = evaluate_governance_decision(base, candidate) change_set = build_change_set_from_decision( decision, @@ -167,7 +147,8 @@ def test_changeset_from_decision_reuses_authoritative_changes_and_context() -> N ) assert change_set.contract_id == decision.contract_id - assert change_set.context == decision.context + assert not hasattr(change_set, "context") + assert not hasattr(decision, "context") assert change_set.changes == decision.changes assert change_set.base_revision_ref == "git:abc123" assert change_set.candidate_revision_ref == "git:def456" From 7b5697e772ce3c56f367c5e5e343fc4aff6117b3 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:51:28 +1000 Subject: [PATCH 515/616] test(history): make proposal history date independent --- tests/test_changeset_history.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/test_changeset_history.py b/tests/test_changeset_history.py index 1abd3157..b52cb9cd 100644 --- a/tests/test_changeset_history.py +++ b/tests/test_changeset_history.py @@ -46,13 +46,12 @@ def _contract(*, name: str) -> OpenDataContractStandard: ) -def _proposal(*, effective_date: str = "2026-09-12"): +def _proposal(*, candidate_name: str = "Orders new"): base_revision = build_contract_revision(_contract(name="Orders old")) - candidate_revision = build_contract_revision(_contract(name="Orders new")) + candidate_revision = build_contract_revision(_contract(name=candidate_name)) proposal = GovernanceService().evaluate_proposal( base_revision.contract, candidate_revision.contract, - effective_date=effective_date, base_revision_ref=base_revision.revision_id, candidate_revision_ref=candidate_revision.revision_id, source="test", @@ -108,12 +107,12 @@ def test_records_exact_proposal_chain_without_recomputing_domain_artifacts( assert link.decision_id == proposal.decision.decision_id -def test_changeset_context_round_trips_and_different_contexts_do_not_overwrite( +def test_repeated_proposal_evaluation_reuses_same_history_identity( tmp_path: Path, ) -> None: service, backend = _service(tmp_path) - first, first_base, first_candidate = _proposal(effective_date="2026-09-12") - second, second_base, second_candidate = _proposal(effective_date="2026-09-13") + first, first_base, first_candidate = _proposal() + second, second_base, second_candidate = _proposal() service.record_proposal( first, @@ -126,10 +125,10 @@ def test_changeset_context_round_trips_and_different_contexts_do_not_overwrite( candidate_revision=second_candidate, ) - assert first.change_set.change_set_id != second.change_set.change_set_id - assert backend.get_change_set(first.change_set.change_set_id).context == first.change_set.context - assert backend.get_change_set(second.change_set.change_set_id).context == second.change_set.context - assert len(backend.list_change_sets("orders-product")) == 2 + assert first.change_set.change_set_id == second.change_set.change_set_id + assert first.decision.decision_id == second.decision.decision_id + assert len(backend.list_change_sets("orders-product")) == 1 + assert len(backend.list_decisions("orders-product")) == 1 def test_rejects_changeset_that_does_not_reference_exact_contract_revisions( @@ -143,7 +142,6 @@ def test_rejects_changeset_that_does_not_reference_exact_contract_revisions( base_revision_ref="git:base", candidate_revision_ref=original.candidate_revision_ref, changes=original.changes, - context=original.context, source=original.source, actor_reference=original.actor_reference, ) @@ -162,13 +160,13 @@ def test_rejects_decision_that_is_not_the_outcome_of_the_changeset( ) -> None: service, _ = _service(tmp_path) proposal, base_revision, candidate_revision = _proposal() - other, _, _ = _proposal(effective_date="2026-09-13") + other, _, _ = _proposal(candidate_name="Orders other") broken_proposal = proposal.__class__( change_set=proposal.change_set, decision=other.decision, ) - with pytest.raises(ValueError, match="context"): + with pytest.raises(ValueError, match="changes"): service.record_proposal( broken_proposal, base_revision=base_revision, From e29046357841fb14093a6fc7051cb5d594d31543 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:51:46 +1000 Subject: [PATCH 516/616] test(governance): make decisions independent of effective date --- tests/test_governance_decision.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_governance_decision.py b/tests/test_governance_decision.py index 466a0445..93f44b6e 100644 --- a/tests/test_governance_decision.py +++ b/tests/test_governance_decision.py @@ -13,8 +13,8 @@ SchemaProperty, ) +from semapact.change_context import ChangeContext from semapact.governance import ( - ChangeContext, DecisionResult, GovernanceDecision, GovernanceReason, @@ -38,7 +38,6 @@ def _evaluate( return evaluate_governance_decision( base, candidate, - context=TEST_CONTEXT, **kwargs, ) @@ -263,7 +262,6 @@ def test_governance_decision_allow_invariants_validator(): decision_id="id1", decision=DecisionResult.ALLOW, contract_id="c1", - context=TEST_CONTEXT, breaking=False, required_version_bump="none", validation=val, @@ -277,7 +275,6 @@ def test_governance_decision_allow_invariants_validator(): decision_id="id2", decision=DecisionResult.ALLOW, contract_id="c1", - context=TEST_CONTEXT, breaking=True, required_version_bump="none", validation=val, @@ -291,7 +288,6 @@ def test_governance_decision_allow_invariants_validator(): decision_id="id3", decision=DecisionResult.ALLOW, contract_id="c1", - context=TEST_CONTEXT, breaking=False, required_version_bump="minor", validation=val, @@ -348,10 +344,17 @@ def test_governance_decision_serialization_and_deserialization(): assert decision == reconstructed assert reconstructed.decision == DecisionResult.REVIEW assert reconstructed.required_version_bump == "minor" - assert reconstructed.context == TEST_CONTEXT + assert not hasattr(reconstructed, "context") assert all(isinstance(reason["code"], str) for reason in dumped_json["reasons"]) +def test_governance_decision_has_no_execution_date_context(): + decision = _evaluate(_make_contract(), _make_contract()) + + assert "context" not in decision.model_dump(mode="json") + assert not hasattr(decision, "context") + + def test_governance_decision_input_immutability(): """Input contracts are untouched after evaluate_governance_decision.""" base = _make_contract() From b104a9a3bb699085882b8a22433e630d3a4a55df Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:52:11 +1000 Subject: [PATCH 517/616] test(governance): remove effective date from public decision protocol --- tests/test_public_governance_decision.py | 42 ++++++++---------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/tests/test_public_governance_decision.py b/tests/test_public_governance_decision.py index 4d74e830..63a480c9 100644 --- a/tests/test_public_governance_decision.py +++ b/tests/test_public_governance_decision.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import date from pathlib import Path import pytest from pydantic import ValidationError as PydanticValidationError @@ -13,9 +12,7 @@ SchemaProperty, ) -from semapact.change_context import ChangeContext from semapact.governance import ( - PublicChangeContextV1, PublicChangeEvidenceV1, PublicGovernanceChangeEvidenceV1, PublicGovernanceChangeV1, @@ -30,8 +27,6 @@ FIXTURES_DIR = Path(__file__).parent / "fixtures" / "governance_decisions" -TEST_CONTEXT = ChangeContext(effective_date=date(2026, 1, 1)) - def _get_schemas(contract: OpenDataContractStandard) -> list[SchemaObject]: return getattr(contract, "schema_", getattr(contract, "schema", [])) or [] @@ -80,7 +75,7 @@ def test_public_decision_allow_scenario(): base = _make_contract() candidate = _make_contract() - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) public_dec = to_public_governance_decision(decision) assert isinstance(public_dec, PublicGovernanceDecisionV1) @@ -89,7 +84,6 @@ def test_public_decision_allow_scenario(): assert public_dec.contract_id == "my-data-contract" assert public_dec.breaking is False assert public_dec.required_version_bump == "none" - assert public_dec.context.effective_date == "2026-01-01" assert public_dec.validation.valid is True assert public_dec.policy.valid is True assert public_dec.policy.id_violation is False @@ -108,7 +102,7 @@ def test_public_decision_allow_scenario(): assert dumped["schemaVersion"] == "1" assert dumped["decisionId"] == decision.decision_id assert dumped["contractId"] == "my-data-contract" - assert dumped["context"] == {"effectiveDate": "2026-01-01"} + assert "context" not in dumped assert dumped["requiredVersionBump"] == "none" assert dumped["evidence"] == {"hasChanges": False, "mergeConflictsCount": 0} assert dumped["changes"] == [] @@ -123,7 +117,7 @@ def test_public_decision_review_scenario(): CustomProperty(property="lifecycleStatus", value="deprecated") ] - decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, cand) public_dec = to_public_governance_decision(decision) assert public_dec.schema_version == "1" @@ -152,7 +146,7 @@ def test_public_decision_breaking_review_scenario(): cand = _make_contract() _get_schemas(cand)[0].properties[1].physicalType = "decimal(8,2)" - decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, cand) public_dec = to_public_governance_decision(decision) assert public_dec.schema_version == "1" @@ -174,7 +168,7 @@ def test_public_decision_block_retired_scenario(): cand = _make_contract(status="retired") _get_schemas(cand)[0].properties[1].description = "New description" - decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, cand) public_dec = to_public_governance_decision(decision) assert public_dec.decision == "BLOCK" @@ -194,7 +188,7 @@ def test_public_decision_block_validation_scenario(): SchemaProperty(name="", logicalType="string", physicalType="", required=True) ) - decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, cand) public_dec = to_public_governance_decision(decision) assert public_dec.decision == "BLOCK" @@ -208,7 +202,6 @@ def test_public_decision_block_validation_scenario(): def test_public_models_direct_instantiation_and_helpers(): """Verify direct instantiation of public models and helper structures.""" - context = PublicChangeContextV1(effective_date="2026-08-28") reason = PublicGovernanceReasonV1( code="TEST_CODE", severity="WARNING", @@ -239,7 +232,6 @@ def test_public_models_direct_instantiation_and_helpers(): decision_id="00000000-0000-0000-0000-000000000000", decision="REVIEW", contract_id="orders", - context=context, breaking=True, required_version_bump="major", reason_codes=("DECIMAL_PRECISION_REDUCED",), @@ -250,7 +242,7 @@ def test_public_models_direct_instantiation_and_helpers(): changes=(change,), ) - assert decision.context.effective_date == "2026-08-28" + assert not hasattr(decision, "context") assert decision.changes[0].evidence[0].source == "MERGE_CONFLICT" assert decision.changes[0].field == "physicalType" assert decision.validation.issues[0].details == {"key": "val"} @@ -264,7 +256,6 @@ def test_public_literals_strict_validation(): "decisionId": "test", "decision": "BANANA", # Invalid decision "contractId": "orders", - "context": {"effectiveDate": "2026-01-01"}, "breaking": False, "requiredVersionBump": "none", "reasonCodes": [], @@ -281,7 +272,6 @@ def test_public_literals_strict_validation(): "decisionId": "test", "decision": "ALLOW", "contractId": "orders", - "context": {"effectiveDate": "2026-01-01"}, "breaking": False, "requiredVersionBump": "huge", # Invalid bump "reasonCodes": [], @@ -309,7 +299,6 @@ def test_public_literals_strict_validation(): "decisionId": "test", "decision": "ALLOW", "contractId": "orders", - "context": {"effectiveDate": "2026-01-01"}, "breaking": False, "requiredVersionBump": "patch", # Invalid bump for v1 "reasonCodes": [], @@ -324,7 +313,7 @@ def test_public_literals_strict_validation(): def test_public_decision_immutability_and_extra_forbid(): """Public governance models are frozen and reject extra fields.""" base = _make_contract() - decision = evaluate_governance_decision(base, base, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, base) public_dec = to_public_governance_decision(decision) # Immutability @@ -338,7 +327,6 @@ def test_public_decision_immutability_and_extra_forbid(): "decisionId": "test", "decision": "ALLOW", "contractId": "orders", - "context": {"effectiveDate": "2026-01-01"}, "breaking": False, "requiredVersionBump": "none", "reasonCodes": [], @@ -374,7 +362,6 @@ def test_public_decision_byte_level_determinism(): decision_id="11111111-1111-1111-1111-111111111111", decision="REVIEW", contract_id="orders", - context=PublicChangeContextV1(effective_date="2026-01-01"), breaking=True, required_version_bump="major", reason_codes=("Z_CODE", "A_CODE"), @@ -397,7 +384,6 @@ def test_public_decision_byte_level_determinism(): decision_id="11111111-1111-1111-1111-111111111111", decision="REVIEW", contract_id="orders", - context=PublicChangeContextV1(effective_date="2026-01-01"), breaking=True, required_version_bump="major", reason_codes=("Z_CODE", "A_CODE"), @@ -421,7 +407,7 @@ def test_public_decision_roundtrip_deserialization(): cand = _make_contract() _get_schemas(cand)[0].properties[1].physicalType = "decimal(8,2)" - decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, cand) public_dec = to_public_governance_decision(decision) json_str = serialize_public_governance_decision(public_dec) @@ -437,7 +423,7 @@ def test_golden_fixtures_match(): """Verify golden JSON fixtures match projected decisions with exact string equality (read-only assertion).""" # 1. ALLOW clean base = _make_contract() - allow_dec = evaluate_governance_decision(base, base, context=TEST_CONTEXT) + allow_dec = evaluate_governance_decision(base, base) allow_pub = to_public_governance_decision(allow_dec) allow_json_str = serialize_public_governance_decision(allow_pub, indent=2) + "\n" expected_allow = (FIXTURES_DIR / "allow_clean_decision.json").read_text(encoding="utf-8") @@ -446,7 +432,7 @@ def test_golden_fixtures_match(): # 2. BREAKING review decimal cand_breaking = _make_contract() _get_schemas(cand_breaking)[0].properties[1].physicalType = "decimal(8,2)" - breaking_dec = evaluate_governance_decision(base, cand_breaking, context=TEST_CONTEXT) + breaking_dec = evaluate_governance_decision(base, cand_breaking) breaking_pub = to_public_governance_decision(breaking_dec) breaking_json_str = serialize_public_governance_decision(breaking_pub, indent=2) + "\n" expected_breaking = (FIXTURES_DIR / "breaking_review_decision.json").read_text(encoding="utf-8") @@ -457,7 +443,7 @@ def test_golden_fixtures_match(): _get_schemas(cand_deprecate)[0].properties[1].customProperties = [ CustomProperty(property="lifecycleStatus", value="deprecated") ] - review_dec = evaluate_governance_decision(base, cand_deprecate, context=TEST_CONTEXT) + review_dec = evaluate_governance_decision(base, cand_deprecate) review_pub = to_public_governance_decision(review_dec) review_json_str = serialize_public_governance_decision(review_pub, indent=2) + "\n" expected_review = (FIXTURES_DIR / "review_deprecate_decision.json").read_text(encoding="utf-8") @@ -467,7 +453,7 @@ def test_golden_fixtures_match(): base_retired = _make_contract(status="retired") cand_retired = _make_contract(status="retired") _get_schemas(cand_retired)[0].properties[1].description = "Retired update" - retired_dec = evaluate_governance_decision(base_retired, cand_retired, context=TEST_CONTEXT) + retired_dec = evaluate_governance_decision(base_retired, cand_retired) retired_pub = to_public_governance_decision(retired_dec) retired_json_str = serialize_public_governance_decision(retired_pub, indent=2) + "\n" expected_retired = (FIXTURES_DIR / "block_retired_decision.json").read_text(encoding="utf-8") @@ -478,7 +464,7 @@ def test_golden_fixtures_match(): _get_schemas(cand_invalid)[0].properties.append( SchemaProperty(name="", logicalType="string", physicalType="", required=True) ) - invalid_dec = evaluate_governance_decision(base, cand_invalid, context=TEST_CONTEXT) + invalid_dec = evaluate_governance_decision(base, cand_invalid) invalid_pub = to_public_governance_decision(invalid_dec) invalid_json_str = serialize_public_governance_decision(invalid_pub, indent=2) + "\n" expected_invalid = (FIXTURES_DIR / "block_validation_decision.json").read_text(encoding="utf-8") From d9a98c825a515f46f7d6e8be92a965ef3011eaa9 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:52:36 +1000 Subject: [PATCH 518/616] test(contractops): remove date from release execution inputs --- tests/test_contractops_execution.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_contractops_execution.py b/tests/test_contractops_execution.py index 7d565109..773d3df5 100644 --- a/tests/test_contractops_execution.py +++ b/tests/test_contractops_execution.py @@ -1,6 +1,5 @@ from __future__ import annotations -from datetime import date import pytest from open_data_contract_standard.model import ( @@ -9,7 +8,6 @@ SchemaProperty, ) -from semapact.change_context import ChangeContext from semapact.contractops import ( VersionAuthorityConfig, build_change_set_from_decision, @@ -21,8 +19,6 @@ from semapact.governance import evaluate_governance_decision -CONTEXT = ChangeContext(effective_date=date(2026, 9, 9)) - def _contract( *, @@ -65,7 +61,7 @@ def _release_context(*, include_created_at: bool = False): contract_name="orders-new", include_created_at=include_created_at, ) - decision = evaluate_governance_decision(base, candidate, context=CONTEXT) + decision = evaluate_governance_decision(base, candidate) change_set = build_change_set_from_decision( decision, base_revision_ref="rev:base", From 226b753e69c0521732df716b28438b4fd80b289f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:52:40 +1000 Subject: [PATCH 519/616] test(contractops): remove date from golden release scenarios --- tests/test_contractops_golden_scenarios.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/test_contractops_golden_scenarios.py b/tests/test_contractops_golden_scenarios.py index bc5c8e86..c7bc1bbb 100644 --- a/tests/test_contractops_golden_scenarios.py +++ b/tests/test_contractops_golden_scenarios.py @@ -13,7 +13,6 @@ ReleaseFinalizer, ReleaseWorkflowService, ) -from semapact.change_context import ChangeContext from semapact.contractops import ( VersionAuthority, VersionAuthorityConfig, @@ -30,7 +29,6 @@ from semapact.governance import DecisionResult, evaluate_governance_decision -CONTEXT = ChangeContext(effective_date=date(2026, 9, 11)) def _contract( @@ -78,7 +76,6 @@ def _allow_chain(): bundle = workflow.assess( _contract(name="orders-old"), _contract(name="orders-new"), - effective_date="2026-09-11", base_revision_ref="git:base", candidate_revision_ref="git:candidate", ) @@ -121,7 +118,6 @@ def test_review_release_requires_exact_publish_approval() -> None: bundle = workflow.assess( _contract(name="orders", include_created_at=False), _contract(name="orders", include_created_at=True), - effective_date="2026-09-11", base_revision_ref="git:base", candidate_revision_ref="git:candidate", ) @@ -144,7 +140,7 @@ def test_review_release_requires_exact_publish_approval() -> None: def test_block_cannot_enter_release_chain() -> None: base = _contract() candidate = _contract(contract_id="other-product") - decision = evaluate_governance_decision(base, candidate, context=CONTEXT) + decision = evaluate_governance_decision(base, candidate) change_set = build_change_set_from_decision( decision, base_revision_ref="git:base", @@ -159,7 +155,7 @@ def test_block_cannot_enter_release_chain() -> None: def test_version_authorities_preserve_same_required_bump() -> None: base = _contract() candidate = _contract(include_created_at=True) - decision = evaluate_governance_decision(base, candidate, context=CONTEXT) + decision = evaluate_governance_decision(base, candidate) change_set = build_change_set_from_decision( decision, base_revision_ref="git:base", From e6b8b376bb881f2f7e3cd38f95d9ddc411d0f883 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:52:43 +1000 Subject: [PATCH 520/616] test(governance): make golden decisions date independent --- tests/test_governance_golden_scenarios.py | 27 ++++++++++------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/tests/test_governance_golden_scenarios.py b/tests/test_governance_golden_scenarios.py index 77a4ef55..d467aae2 100644 --- a/tests/test_governance_golden_scenarios.py +++ b/tests/test_governance_golden_scenarios.py @@ -15,7 +15,6 @@ import yaml from open_data_contract_standard.model import OpenDataContractStandard -from semapact.change_context import ChangeContext from semapact.versioning import RequiredBump from semapact.governance import ( DecisionResult, @@ -27,7 +26,6 @@ from semapact.lifecycle.merge_engine import MergeConflict FIXTURES_DIR = Path(__file__).parent / "fixtures" / "governance_scenarios" -TEST_CONTEXT = ChangeContext(effective_date=date(2026, 1, 1)) @dataclass(frozen=True) @@ -363,7 +361,6 @@ def test_governance_golden_scenarios(scenario: GovernanceGoldenScenario) -> None decision = evaluate_governance_decision( base_contract, cand_contract, - context=TEST_CONTEXT, merge_conflicts=scenario.merge_conflicts, ) @@ -417,7 +414,7 @@ def test_lifecycle_draft_entity_skips_breaking_checks() -> None: base = _load_contract_from_yaml(scenario_dir / "base.yaml") cand = _load_contract_from_yaml(scenario_dir / "candidate.yaml") - decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, cand) assert decision.decision == DecisionResult.REVIEW assert decision.breaking is False @@ -431,7 +428,7 @@ def test_lifecycle_deprecated_entity_skips_breaking_checks() -> None: base = _load_contract_from_yaml(scenario_dir / "base.yaml") cand = _load_contract_from_yaml(scenario_dir / "candidate.yaml") - decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, cand) assert decision.decision == DecisionResult.REVIEW assert decision.breaking is False @@ -445,7 +442,7 @@ def test_lifecycle_active_to_retired_transition_reviewable() -> None: base = _load_contract_from_yaml(scenario_dir / "base.yaml") cand = _load_contract_from_yaml(scenario_dir / "candidate.yaml") - decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, cand) assert decision.decision == DecisionResult.REVIEW assert decision.policy.retired_violation is False @@ -461,7 +458,7 @@ def test_lifecycle_retired_contract_mutation_blocked() -> None: base = _load_contract_from_yaml(scenario_dir / "base.yaml") cand = _load_contract_from_yaml(scenario_dir / "candidate.yaml") - decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, cand) assert decision.decision == DecisionResult.BLOCK assert decision.policy.retired_violation is True @@ -482,7 +479,7 @@ def test_physical_name_identity_stability() -> None: base = _load_contract_from_yaml(scenario_dir / "base.yaml") cand = _load_contract_from_yaml(scenario_dir / "candidate.yaml") - decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, cand) assert decision.decision == DecisionResult.REVIEW assert decision.breaking is False @@ -532,13 +529,13 @@ def test_repeated_evaluation_determinism() -> None: base = _load_contract_from_yaml(scenario_dir / "base.yaml") cand = _load_contract_from_yaml(scenario_dir / "candidate.yaml") - first_dec = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + first_dec = evaluate_governance_decision(base, cand) first_json = serialize_public_governance_decision( to_public_governance_decision(first_dec), indent=2 ) for _ in range(10): - subsequent_dec = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + subsequent_dec = evaluate_governance_decision(base, cand) subsequent_json = serialize_public_governance_decision( to_public_governance_decision(subsequent_dec), indent=2 ) @@ -603,8 +600,8 @@ def test_non_semantic_key_ordering_determinism() -> None: base_1 = OpenDataContractStandard.model_validate(base_dict_1) base_2 = OpenDataContractStandard.model_validate(base_dict_2) - dec_1 = evaluate_governance_decision(base_1, base_1, context=TEST_CONTEXT) - dec_2 = evaluate_governance_decision(base_2, base_2, context=TEST_CONTEXT) + dec_1 = evaluate_governance_decision(base_1, base_1) + dec_2 = evaluate_governance_decision(base_2, base_2) assert dec_1.decision_id == dec_2.decision_id assert to_public_governance_decision(dec_1).to_canonical_json( @@ -632,10 +629,10 @@ def test_merge_conflict_ordering_determinism() -> None: ) dec_forward = evaluate_governance_decision( - base, base, context=TEST_CONTEXT, merge_conflicts=(c1, c2) + base, base, merge_conflicts=(c1, c2) ) dec_reverse = evaluate_governance_decision( - base, base, context=TEST_CONTEXT, merge_conflicts=(c2, c1) + base, base, merge_conflicts=(c2, c1) ) assert dec_forward.decision_id == dec_reverse.decision_id @@ -665,7 +662,7 @@ def test_fixtures_are_read_only() -> None: b = _load_contract_from_yaml(s_dir / "base.yaml") c = _load_contract_from_yaml(s_dir / "candidate.yaml") d = evaluate_governance_decision( - b, c, context=TEST_CONTEXT, merge_conflicts=scenario.merge_conflicts + b, c, merge_conflicts=scenario.merge_conflicts ) pub = to_public_governance_decision(d) actual = serialize_public_governance_decision(pub, indent=2) + "\n" From b9ca305ca1b962e6cb6c1d63fe795f54c88ab473 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:52:46 +1000 Subject: [PATCH 521/616] test(history): remove date from changeset integrity --- tests/test_history_integrity.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_history_integrity.py b/tests/test_history_integrity.py index 0dc8ed7b..cc2cb849 100644 --- a/tests/test_history_integrity.py +++ b/tests/test_history_integrity.py @@ -1,6 +1,5 @@ from __future__ import annotations -from datetime import date from pathlib import Path import pytest @@ -12,7 +11,6 @@ from semapact.application.services.evolution import EvolutionChainService from semapact.application.services.history_integrity import HistoryIntegrityService -from semapact.change_context import ChangeContext from semapact.contractops import build_change_set_from_decision from semapact.governance import GovernanceDecision, evaluate_governance_decision from semapact.history import ( @@ -24,7 +22,6 @@ from semapact.platforms.git import GitWorkingTreeHistoryRepository -CONTEXT = ChangeContext(effective_date=date(2026, 9, 15)) def _contract(*, name: str) -> OpenDataContractStandard: @@ -55,7 +52,6 @@ def _decision() -> GovernanceDecision: return evaluate_governance_decision( _contract(name="orders-base"), _contract(name="orders-candidate"), - context=CONTEXT, ) From 5dcfc52fa3785a2c299f8ceaeed688242f905460 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:52:51 +1000 Subject: [PATCH 522/616] test(governance): remove date from gate decisions --- tests/test_governance_gate.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_governance_gate.py b/tests/test_governance_gate.py index 0dd17394..582a2d4d 100644 --- a/tests/test_governance_gate.py +++ b/tests/test_governance_gate.py @@ -9,7 +9,6 @@ from semapact.exceptions import GovernanceBlockedError, GovernanceReviewRequiredError from semapact.governance import ( - ChangeContext, ChangeEvidence, DecisionResult, GovernanceDecision, @@ -25,7 +24,6 @@ ) -TEST_CONTEXT = ChangeContext(effective_date=date(2026, 1, 1)) def _make_decision(result: DecisionResult) -> GovernanceDecision: @@ -58,7 +56,6 @@ def _make_decision(result: DecisionResult) -> GovernanceDecision: decision_id="dec-123", decision=result, contract_id="contract-test", - context=TEST_CONTEXT, breaking=breaking, required_version_bump=bump, validation=ValidationOutcome(valid=val_valid), From 4bee1eb0ec9f3e814989a77b2c5b691a0fd63830 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:53:00 +1000 Subject: [PATCH 523/616] test(pipeline): separate merge date from decision payload --- tests/test_pipeline_manifest.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_pipeline_manifest.py b/tests/test_pipeline_manifest.py index 75d5b400..af905745 100644 --- a/tests/test_pipeline_manifest.py +++ b/tests/test_pipeline_manifest.py @@ -13,7 +13,8 @@ ) from semapact.exceptions import GovernanceReviewRequiredError -from semapact.governance import ChangeContext, enforce_governance_gate +from semapact.change_context import ChangeContext +from semapact.governance import enforce_governance_gate from semapact.governance.evaluator import evaluate_governance_decision from semapact.lifecycle.merge_engine import MergeResult from semapact.orchestrator.pipeline import ContractPipeline, _build_manifest_payload @@ -69,7 +70,6 @@ def test_manifest_breaking_changes_project_authoritative_decision() -> None: decision = evaluate_governance_decision( base, candidate, - context=TEST_CONTEXT, ) decision_payload = decision.model_dump(mode="json") manifest = _build_manifest_payload(decision) @@ -92,7 +92,6 @@ def test_manifest_non_breaking_change_has_no_breaking_projection() -> None: decision = evaluate_governance_decision( base, candidate, - context=TEST_CONTEXT, ) manifest = _build_manifest_payload(decision) From 6c2ec27163d1d5416c5e7454f70c4a82e37a5dc6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:53:02 +1000 Subject: [PATCH 524/616] test(governance): evaluation is independent of merge date --- tests/test_governance_analysis_boundary.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_governance_analysis_boundary.py b/tests/test_governance_analysis_boundary.py index f0b81990..8ca209f6 100644 --- a/tests/test_governance_analysis_boundary.py +++ b/tests/test_governance_analysis_boundary.py @@ -156,8 +156,8 @@ def test_governance_decision_evaluation_is_pure_and_deterministic( _block_external_side_effects(monkeypatch) - first = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) - second = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + first = evaluate_governance_decision(base, candidate) + second = evaluate_governance_decision(base, candidate) assert first == second assert first.decision_id == second.decision_id @@ -182,6 +182,6 @@ def test_governance_service_evaluate_preserves_analysis_boundary( effective_date=TEST_CONTEXT.effective_date, ) - assert decision.context == TEST_CONTEXT + assert not hasattr(decision, "context") assert base == base_before assert candidate == candidate_before From 13088c31bbee65f4da42bba06a0b0cdb178e329d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:53:39 +1000 Subject: [PATCH 525/616] test(governance): remove date context from change evaluation --- tests/test_governance_change_integration.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/test_governance_change_integration.py b/tests/test_governance_change_integration.py index 2bda4fc9..488b08d5 100644 --- a/tests/test_governance_change_integration.py +++ b/tests/test_governance_change_integration.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import date from typing import Any from open_data_contract_standard.model import ( AuthoritativeDefinition, @@ -17,7 +16,6 @@ Server, ) -from semapact.change_context import ChangeContext from semapact.lifecycle.change_classification import classify_contract_change from semapact.governance import DecisionResult, evaluate_governance_decision from semapact.governance_codes import GovernanceReasonCode @@ -29,7 +27,6 @@ from semapact.lifecycle.policy import evaluate_merge_policy -TEST_CONTEXT = ChangeContext(effective_date=date(2026, 8, 15)) def _make_active_contract(**kwargs: Any) -> OpenDataContractStandard: @@ -448,7 +445,7 @@ def test_evaluator_attaches_merge_conflict_evidence(self) -> None: ] decision = evaluate_governance_decision( - base, cand, context=TEST_CONTEXT, merge_conflicts=conflicts + base, cand, merge_conflicts=conflicts ) assert decision.evidence.has_changes is True @@ -478,7 +475,7 @@ def test_unmatched_merge_conflict_remains_in_reasons(self) -> None: ] decision = evaluate_governance_decision( - base, cand, context=TEST_CONTEXT, merge_conflicts=conflicts + base, cand, merge_conflicts=conflicts ) assert any( @@ -496,7 +493,7 @@ def test_retired_base_mutation_blocks_even_with_validation_error(self) -> None: SchemaProperty(name="id", logicalType="string", physicalType="text") ) - decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, cand) assert decision.decision == DecisionResult.BLOCK assert any( @@ -514,7 +511,7 @@ def test_retired_base_unhandled_or_metadata_mutation_blocks(self) -> None: cand = _make_active_contract(status="retired") cand.price = Pricing(priceAmount=999.0, priceCurrency="USD") - decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, cand) assert decision.decision == DecisionResult.BLOCK assert any( From f4b19340c298762e52ccb56d8c3c4cacc078d851 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:53:44 +1000 Subject: [PATCH 526/616] test(governance): make reason codes date independent --- tests/test_governance_reason_codes.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_governance_reason_codes.py b/tests/test_governance_reason_codes.py index 68c4d963..c6013e14 100644 --- a/tests/test_governance_reason_codes.py +++ b/tests/test_governance_reason_codes.py @@ -11,7 +11,6 @@ ) from semapact.governance import ( - ChangeContext, GOVERNANCE_REASON_REGISTRY, GovernanceReasonCode, GovernanceSeverity, @@ -20,7 +19,6 @@ from semapact.lifecycle.policy import evaluate_merge_policy -TEST_CONTEXT = ChangeContext(effective_date=date(2026, 1, 1)) ISSUE_78_PUBLIC_CODES = { GovernanceReasonCode.CONTRACT_ID_CHANGED, @@ -194,8 +192,8 @@ def test_reason_code_serialization_is_stable_and_message_is_descriptive_only(): candidate = _contract() candidate.schema_[0].properties = [] - first = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) - second = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + first = evaluate_governance_decision(base, candidate) + second = evaluate_governance_decision(base, candidate) first_json = first.model_dump(mode="json") second_json = second.model_dump(mode="json") From 8d4b40b125cd1f44ca60d8f66d982385f3576c18 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:53:50 +1000 Subject: [PATCH 527/616] test(contractops): remove date from authorization inputs --- tests/test_contractops_authorization.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_contractops_authorization.py b/tests/test_contractops_authorization.py index 91f41b49..c5d3f7bb 100644 --- a/tests/test_contractops_authorization.py +++ b/tests/test_contractops_authorization.py @@ -1,6 +1,5 @@ from __future__ import annotations -from datetime import date import pytest from open_data_contract_standard.model import ( @@ -9,7 +8,6 @@ SchemaProperty, ) -from semapact.change_context import ChangeContext from semapact.contractops import ( AuthorizationReason, ReleasePlan, @@ -27,7 +25,6 @@ from semapact.governance.gate import GovernanceOperation -CONTEXT = ChangeContext(effective_date=date(2026, 9, 9)) def _contract( @@ -75,7 +72,7 @@ def _release_context(kind: str): else: # pragma: no cover - test helper guard raise ValueError(kind) - decision = evaluate_governance_decision(base, candidate, context=CONTEXT) + decision = evaluate_governance_decision(base, candidate) change_set = build_change_set_from_decision( decision, base_revision_ref="rev:base", From 4effe26e618bccdb8f8db869c917b0dfcd610a72 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:53:53 +1000 Subject: [PATCH 528/616] test(contractops): remove date from artifact rehydration --- tests/test_contractops_artifact_rehydration.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_contractops_artifact_rehydration.py b/tests/test_contractops_artifact_rehydration.py index 415a7b58..2f319be6 100644 --- a/tests/test_contractops_artifact_rehydration.py +++ b/tests/test_contractops_artifact_rehydration.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -from datetime import date import pytest from open_data_contract_standard.model import ( @@ -15,7 +14,6 @@ build_contract_release, build_release_bundle, ) -from semapact.change_context import ChangeContext from semapact.contractops import ( ChangeSet, ContractRelease, @@ -31,7 +29,6 @@ from semapact.governance import evaluate_governance_decision -CONTEXT = ChangeContext(effective_date=date(2026, 9, 11)) def _contract(*, name: str) -> OpenDataContractStandard: @@ -61,7 +58,7 @@ def _contract(*, name: str) -> OpenDataContractStandard: def _artifact_chain(): base = _contract(name="orders-old") candidate = _contract(name="orders-new") - decision = evaluate_governance_decision(base, candidate, context=CONTEXT) + decision = evaluate_governance_decision(base, candidate) change_set = build_change_set_from_decision( decision, base_revision_ref="git:base", From 78a0ee2e706cfa9e5ceadd5b799a0cd5b10ba9a4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:53:57 +1000 Subject: [PATCH 529/616] test(lifecycle): import mutation context from lifecycle boundary --- tests/test_identity_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_identity_integration.py b/tests/test_identity_integration.py index be186ec4..480285e8 100644 --- a/tests/test_identity_integration.py +++ b/tests/test_identity_integration.py @@ -5,7 +5,7 @@ from semapact.lifecycle.change_classification import classify_contract_change from semapact.exceptions import ValidationError -from semapact.governance import ChangeContext +from semapact.change_context import ChangeContext from semapact.lifecycle.merge_engine import ContractMergeEngine from semapact.lifecycle.policy import evaluate_merge_policy From 5f8cdc966e0d057ba8075052d58f51565389f1ac Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 14:54:01 +1000 Subject: [PATCH 530/616] test(lifecycle): import mutation context directly --- tests/test_semapact_lifecycle_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_semapact_lifecycle_cli.py b/tests/test_semapact_lifecycle_cli.py index 659c4086..4bf154f7 100644 --- a/tests/test_semapact_lifecycle_cli.py +++ b/tests/test_semapact_lifecycle_cli.py @@ -6,7 +6,7 @@ from semapact.core.lifecycle_cli import apply_lifecycle from semapact.exceptions import GovernanceReviewRequiredError -from semapact.governance import ChangeContext +from semapact.change_context import ChangeContext from semapact.utils.yaml_utils import load_yaml Args = namedtuple( From c7b7a81dd3205177d9e5ec21bc177b14b068e84c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 16:46:16 +1000 Subject: [PATCH 531/616] test(lifecycle): import mutation context from canonical module From a4684836c217bbeda6d9df232f4c9eba0fcc3d45 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 16:46:19 +1000 Subject: [PATCH 532/616] test(lifecycle): import mutation context from canonical module --- tests/test_fail_closed_governance_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_fail_closed_governance_integration.py b/tests/test_fail_closed_governance_integration.py index b9963e85..18472a31 100644 --- a/tests/test_fail_closed_governance_integration.py +++ b/tests/test_fail_closed_governance_integration.py @@ -16,8 +16,8 @@ from semapact.core.lifecycle_cli import apply_lifecycle from semapact.devops.ci_cd import evaluate_ci_gate from semapact.exceptions import GovernanceBlockedError, GovernanceReviewRequiredError +from semapact.change_context import ChangeContext from semapact.governance import ( - ChangeContext, GovernanceOperation, evaluate_governance_decision, ) From 0dc921924293a59dc0f03e34d6227d76ee74dfe6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 16:46:23 +1000 Subject: [PATCH 533/616] test(lifecycle): import mutation context from canonical module --- tests/test_semapact_devops_readable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_semapact_devops_readable.py b/tests/test_semapact_devops_readable.py index 6e4e949c..bcc087d9 100644 --- a/tests/test_semapact_devops_readable.py +++ b/tests/test_semapact_devops_readable.py @@ -10,7 +10,7 @@ from semapact.devops.audit import build_audit_metadata from semapact.devops.ci_cd import evaluate_ci_gate, write_ci_summary from semapact.devops.pr_creator import AzureDevOpsConfig, PullRequestCreator -from semapact.governance import ChangeContext +from semapact.change_context import ChangeContext TEST_CONTEXT = ChangeContext(effective_date=date(2026, 8, 13)) From 540dbbd3ff78b8eada8f41e723ff2a9537537d46 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 16:46:26 +1000 Subject: [PATCH 534/616] test(lifecycle): import mutation context from canonical module --- tests/test_semapact_lifecycle_merge_engine_readable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_semapact_lifecycle_merge_engine_readable.py b/tests/test_semapact_lifecycle_merge_engine_readable.py index e41e3c30..5267636f 100644 --- a/tests/test_semapact_lifecycle_merge_engine_readable.py +++ b/tests/test_semapact_lifecycle_merge_engine_readable.py @@ -12,7 +12,7 @@ ) import semapact.lifecycle.merge_engine as merge_engine -from semapact.governance import ChangeContext +from semapact.change_context import ChangeContext from semapact.lifecycle.merge_engine import ContractMergeEngine From 1253f582f4f859ad842efa5899cb71627b2a3314 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 16:46:33 +1000 Subject: [PATCH 535/616] test(lifecycle): import mutation context from canonical module --- tests/test_semapact_merge_engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_semapact_merge_engine.py b/tests/test_semapact_merge_engine.py index 6f6b37b7..f21a63b6 100644 --- a/tests/test_semapact_merge_engine.py +++ b/tests/test_semapact_merge_engine.py @@ -11,7 +11,7 @@ ) import semapact.lifecycle.merge_engine as merge_engine -from semapact.governance import ChangeContext +from semapact.change_context import ChangeContext from semapact.lifecycle.merge_engine import ContractMergeEngine From c2e16d8d8441cd51ed3a4f70a8069f78618030ce Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 16:46:37 +1000 Subject: [PATCH 536/616] test(lifecycle): import mutation context from canonical module From c87512194f9dc4fcf4c7e8f8068f70b7c99e4c1a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 16:46:40 +1000 Subject: [PATCH 537/616] test(databricks): remove date from deployment assessment From 20b13324aefa6e290a8a0076af07b1f7288d108f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:28:05 +1000 Subject: [PATCH 538/616] test(context): scope effective date to lifecycle mutation --- tests/test_change_context.py | 40 ++++++------------------------------ 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/tests/test_change_context.py b/tests/test_change_context.py index f4f8629b..a9f76dfa 100644 --- a/tests/test_change_context.py +++ b/tests/test_change_context.py @@ -13,7 +13,8 @@ ) from pydantic import ValidationError as PydanticValidationError -from semapact.governance import ChangeContext, evaluate_governance_decision +from semapact.change_context import ChangeContext +from semapact.governance import evaluate_governance_decision from semapact.lifecycle.merge_engine import ContractMergeEngine @@ -82,45 +83,16 @@ def test_change_context_is_immutable_and_serializes_effective_date() -> None: context.effective_date = date(2026, 8, 14) # type: ignore[misc] -def test_same_inputs_and_context_produce_identical_decision() -> None: +def test_governance_decision_is_independent_of_business_effective_date() -> None: base = _contract() candidate = _contract(include_legacy_property=False) - context = ChangeContext(effective_date=date(2026, 8, 13)) - first = evaluate_governance_decision(base, candidate, context=context) - second = evaluate_governance_decision(base, candidate, context=context) + first = evaluate_governance_decision(base, candidate) + second = evaluate_governance_decision(base, candidate) assert first == second assert first.decision_id == second.decision_id - assert first.model_dump(mode="json") == second.model_dump(mode="json") - assert first.context == context - - -def test_governance_evaluation_rejects_missing_upstream_context() -> None: - base = _contract() - candidate = _contract(include_legacy_property=False) - - with pytest.raises(TypeError): - evaluate_governance_decision(base, candidate) # type: ignore[call-arg] - - -def test_effective_date_is_part_of_decision_identity_when_explicit() -> None: - base = _contract() - candidate = _contract(include_legacy_property=False) - - first = evaluate_governance_decision( - base, - candidate, - context=ChangeContext(effective_date=date(2026, 8, 13)), - ) - second = evaluate_governance_decision( - base, - candidate, - context=ChangeContext(effective_date=date(2026, 8, 14)), - ) - - assert first.decision == second.decision - assert first.decision_id != second.decision_id + assert "context" not in first.model_dump(mode="json") def test_auto_deprecation_uses_explicit_effective_date() -> None: From 0ace1b29ac33bfbe3e010adafb542f4b51cea4e2 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:28:13 +1000 Subject: [PATCH 539/616] test(pipeline): separate mutation context from governance --- tests/test_semapact_orchestrator_pipeline_readable.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_semapact_orchestrator_pipeline_readable.py b/tests/test_semapact_orchestrator_pipeline_readable.py index ad1e9f50..38a774d8 100644 --- a/tests/test_semapact_orchestrator_pipeline_readable.py +++ b/tests/test_semapact_orchestrator_pipeline_readable.py @@ -14,7 +14,8 @@ from semapact.lifecycle.merge_engine import MergeConflict, MergeResult from semapact.lifecycle.policy import BreakingChange, PolicyEvaluation from semapact.exceptions import GovernanceBlockedError -from semapact.governance import ChangeContext, GovernanceGateResult +from semapact.change_context import ChangeContext +from semapact.governance import GovernanceGateResult from semapact.orchestrator.pipeline import ContractPipeline from deltalake import write_deltalake @@ -137,7 +138,6 @@ def test_pipeline_prepare_ci_cd_artifacts_writes_manifest_and_outputs( decision = evaluate_governance_decision( merged_contract, merged_contract, - context=TEST_CONTEXT, ) artifacts = pipeline.prepare_ci_cd_artifacts( From e2e8a14a043a5d3d5f7d9790a44f6d3c7b81ecd1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:28:19 +1000 Subject: [PATCH 540/616] test(databricks): make bundle assessment date independent --- tests/test_databricks_convergence_e2e.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_databricks_convergence_e2e.py b/tests/test_databricks_convergence_e2e.py index 80b3bf8c..968cc19f 100644 --- a/tests/test_databricks_convergence_e2e.py +++ b/tests/test_databricks_convergence_e2e.py @@ -3,7 +3,7 @@ import argparse import json import re -from datetime import date, datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace @@ -16,7 +16,6 @@ from semapact.application.services.deployment import DeploymentService from semapact.application.services.deployment_workflow import DeploymentWorkflowService -from semapact.change_context import ChangeContext from semapact.deployment import ( DeploymentPlan, DeploymentPreview, @@ -39,7 +38,6 @@ from semapact.reconciliation import RuntimeDriftStatus, classify_reconciliation_status -_CONTEXT = ChangeContext(effective_date=date(2026, 9, 19)) _SOURCE = "workspace:golden" _RUNTIME_TARGET = "main.silver" _CAPTURED_AT = datetime(2026, 9, 19, 10, 0, tzinfo=timezone.utc) @@ -297,7 +295,6 @@ def test_bundle_ci_to_cd_create_and_fresh_verify_converge() -> None: bundle = service.assess( base, candidate, - effective_date=_CONTEXT.effective_date, base_revision_ref="git:base", candidate_revision_ref="git:candidate", target=_target(), @@ -331,7 +328,6 @@ def test_bundle_cd_replans_against_runtime_changed_after_ci() -> None: bundle = service.assess( base, candidate, - effective_date=_CONTEXT.effective_date, base_revision_ref="git:base", candidate_revision_ref="git:candidate", target=_target(), From 64f81a949119b5c6c949ac3f72c85395f2352531 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:29:52 +1000 Subject: [PATCH 541/616] fix(lifecycle): preserve mutation date without coupling governance --- semapact/core/lifecycle_cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/core/lifecycle_cli.py b/semapact/core/lifecycle_cli.py index 30478c9e..8031d5b0 100644 --- a/semapact/core/lifecycle_cli.py +++ b/semapact/core/lifecycle_cli.py @@ -40,7 +40,8 @@ def apply_lifecycle( args.schema, args.property, target_status, - ) + context=context, + ) elif getattr(args, "schema", None): _apply_schema( candidate_contract, @@ -55,7 +56,6 @@ def apply_lifecycle( decision = evaluate_governance_decision( base_contract, candidate_contract, - context=context, ) enforce_governance_gate(decision, GovernanceOperation.APPLY) From a964a14ec907992663bf8477cdea14bc069ea6e3 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:30:04 +1000 Subject: [PATCH 542/616] fix(lifecycle): preserve mutation date without coupling governance From 1d8d795d55bd831ef6179a78eee94017250ef462 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:30:13 +1000 Subject: [PATCH 543/616] fix(release): remove stale effective date from plan command --- semapact/interfaces/commands/release_cmd.py | 1 - 1 file changed, 1 deletion(-) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index fc146a61..2fd1083e 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -166,7 +166,6 @@ def run_release_plan(args: argparse.Namespace) -> dict[str, Any]: result = ReleasePlanningService().plan( base_contract, candidate_contract, - effective_date=args.effective_date, base_revision_ref=args.base_revision_ref, candidate_revision_ref=args.candidate_revision_ref, authority_reference=args.authority_reference, From 1bf2e25115dd27c1bb21d639e30dd108b7c98103 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:30:45 +1000 Subject: [PATCH 544/616] test(cli): remove decision date context --- tests/interfaces/test_cli_outcomes.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/interfaces/test_cli_outcomes.py b/tests/interfaces/test_cli_outcomes.py index f7e4393f..9762f67d 100644 --- a/tests/interfaces/test_cli_outcomes.py +++ b/tests/interfaces/test_cli_outcomes.py @@ -5,7 +5,6 @@ import pytest -from semapact.change_context import ChangeContext from semapact.versioning import RequiredBump from semapact.exceptions import ( GovernanceBlockedError, @@ -44,7 +43,6 @@ def _make_dummy_decision( decision_id="test-dec-1", decision=decision, contract_id="test-contract", - context=ChangeContext(effective_date="2026-08-29"), breaking=breaking, required_version_bump=bump, validation=ValidationOutcome(valid=True), From 4f98968769d665af4cbee87103a9f42e9b684b26 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:30:48 +1000 Subject: [PATCH 545/616] test(architecture): remove date from release and governance identity --- tests/test_architecture_consolidation.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_architecture_consolidation.py b/tests/test_architecture_consolidation.py index 87f78916..2f1a1b7b 100644 --- a/tests/test_architecture_consolidation.py +++ b/tests/test_architecture_consolidation.py @@ -14,7 +14,6 @@ ReleaseFinalizer, ReleaseWorkflowService, ) -from semapact.change_context import ChangeContext from semapact.contractops import ( VersionAuthority, VersionAuthorityConfig, @@ -33,7 +32,6 @@ from semapact.versioning import normalize_semver -CONTEXT = ChangeContext(effective_date=date(2026, 9, 10)) def _contract( @@ -74,7 +72,6 @@ def _finalized_allow_release(): bundle = workflow.assess( _contract(name="Orders old"), _contract(name="Orders new"), - effective_date="2026-09-10", base_revision_ref="rev:base", candidate_revision_ref="rev:candidate", ) @@ -154,7 +151,7 @@ def test_same_runtime_namespace_on_another_source_has_distinct_plan_identity() - def test_version_authorities_preserve_required_bump() -> None: base = _contract() candidate = _contract(include_created_at=True) - decision = evaluate_governance_decision(base, candidate, context=CONTEXT) + decision = evaluate_governance_decision(base, candidate) change_set = build_change_set_from_decision( decision, base_revision_ref="rev:base", From d6605769962e8ef97f9109bad94edc1808fe04aa Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:30:52 +1000 Subject: [PATCH 546/616] test(import): keep effective date only on merge mutation --- tests/test_delta_import_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_delta_import_integration.py b/tests/test_delta_import_integration.py index 5aa263eb..162c9410 100644 --- a/tests/test_delta_import_integration.py +++ b/tests/test_delta_import_integration.py @@ -146,7 +146,7 @@ def test_local_delta_import_preserves_governance_regression_path(tmp_path: Path) prop for prop in merged_orders.properties or [] if prop.name == "legacy_col" ) - assert analysis.decision.context == analysis.context + assert not hasattr(analysis.decision, "context") assert analysis.decision.decision == DecisionResult.REVIEW assert analysis.decision.required_version_bump == "minor" assert _custom_property_value(legacy, "lifecycleStatus") == "deprecated" From e32811fb60fbc958cfe4bec1c3cc7ea841f169c8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:30:56 +1000 Subject: [PATCH 547/616] test(governance): remove date from read-only evaluation --- tests/test_governance_analysis_boundary.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_governance_analysis_boundary.py b/tests/test_governance_analysis_boundary.py index 8ca209f6..2f33d5f1 100644 --- a/tests/test_governance_analysis_boundary.py +++ b/tests/test_governance_analysis_boundary.py @@ -179,7 +179,6 @@ def test_governance_service_evaluate_preserves_analysis_boundary( decision = GovernanceService().evaluate( base, candidate, - effective_date=TEST_CONTEXT.effective_date, ) assert not hasattr(decision, "context") From 6f2d369d86c7710d83f073594993f85dddeda65e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:31:00 +1000 Subject: [PATCH 548/616] test(devops): remove date from governance decision fixtures --- tests/test_semapact_devops_readable.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test_semapact_devops_readable.py b/tests/test_semapact_devops_readable.py index bcc087d9..ba61e5c2 100644 --- a/tests/test_semapact_devops_readable.py +++ b/tests/test_semapact_devops_readable.py @@ -10,9 +10,7 @@ from semapact.devops.audit import build_audit_metadata from semapact.devops.ci_cd import evaluate_ci_gate, write_ci_summary from semapact.devops.pr_creator import AzureDevOpsConfig, PullRequestCreator -from semapact.change_context import ChangeContext -TEST_CONTEXT = ChangeContext(effective_date=date(2026, 8, 13)) def _creator() -> PullRequestCreator: @@ -56,7 +54,6 @@ def test_ci_gate_allows_only_when_validation_and_policy_are_valid(): validation=val, policy=pol, evidence=evi, - context=TEST_CONTEXT, ) dec_review = GovernanceDecision( decision_id="id2", @@ -67,7 +64,6 @@ def test_ci_gate_allows_only_when_validation_and_policy_are_valid(): validation=val, policy=pol, evidence=evi, - context=TEST_CONTEXT, ) dec_block = GovernanceDecision( decision_id="id3", @@ -78,7 +74,6 @@ def test_ci_gate_allows_only_when_validation_and_policy_are_valid(): validation=ValidationOutcome(valid=False), policy=PolicyOutcome(valid=False), evidence=evi, - context=TEST_CONTEXT, ) res_allow = evaluate_ci_gate(dec_allow) From 6943931a434f82af0a6046a1b6d5c035f57e4cb3 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:31:25 +1000 Subject: [PATCH 549/616] test(history): remove date from persisted governance artifacts --- tests/test_history_repository.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_history_repository.py b/tests/test_history_repository.py index c69ac4f4..803c9806 100644 --- a/tests/test_history_repository.py +++ b/tests/test_history_repository.py @@ -1,6 +1,5 @@ from __future__ import annotations -from datetime import date from pathlib import Path import pytest @@ -10,7 +9,6 @@ SchemaProperty, ) -from semapact.change_context import ChangeContext from semapact.contractops import build_change_set_from_decision from semapact.governance import GovernanceDecision, evaluate_governance_decision from semapact.history import ( @@ -23,7 +21,6 @@ from semapact.platforms.git import GitWorkingTreeHistoryRepository -CONTEXT = ChangeContext(effective_date=date(2026, 9, 12)) def _contract(*, name: str) -> OpenDataContractStandard: @@ -54,7 +51,6 @@ def _decision(*, candidate_name: str) -> GovernanceDecision: return evaluate_governance_decision( _contract(name="orders-base"), _contract(name=candidate_name), - context=CONTEXT, ) @@ -78,7 +74,7 @@ def test_artifacts_round_trip_through_segregated_repository_ports( assert decisions.get_decision(decision.decision_id) == decision assert change_sets.get_change_set(change_set.change_set_id) == change_set - assert change_sets.get_change_set(change_set.change_set_id).context == CONTEXT + assert not hasattr(change_sets.get_change_set(change_set.change_set_id), "context") def test_identical_writes_are_idempotent(tmp_path: Path) -> None: From 3ae0c016ebbaa9f4d7dfd74f538f249f2049239c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:31:29 +1000 Subject: [PATCH 550/616] test(lifecycle): keep date on mutation, not evaluation From b2fa48ca44c9560e71a7c4a546a0e7e524e69f89 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:31:32 +1000 Subject: [PATCH 551/616] test(merge): separate mutation date from governance evaluation --- tests/test_semapact_merge_engine.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_semapact_merge_engine.py b/tests/test_semapact_merge_engine.py index f21a63b6..d1c2de5f 100644 --- a/tests/test_semapact_merge_engine.py +++ b/tests/test_semapact_merge_engine.py @@ -353,7 +353,6 @@ def test_merge_engine_rejects_retired_contract_modification(): decision = evaluate_governance_decision( existing, result.contract, - context=TEST_CONTEXT, merge_conflicts=result.conflicts, ) assert decision.decision == DecisionResult.BLOCK From ecdcde8d55329e54adb747e77896a34c20d2cdcc Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:32:01 +1000 Subject: [PATCH 552/616] test(retired): remove date from read-only governance paths --- tests/test_retired_immutability.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/test_retired_immutability.py b/tests/test_retired_immutability.py index 52c28f7c..7787fa33 100644 --- a/tests/test_retired_immutability.py +++ b/tests/test_retired_immutability.py @@ -102,7 +102,7 @@ def test_retired_unchanged_allows(self) -> None: base = _make_retired_contract() candidate = _make_retired_contract() - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.ALLOW assert decision.policy.retired_violation is False @@ -118,7 +118,7 @@ def test_retired_descriptive_metadata_change_blocks(self) -> None: candidate.description = Description(usage="Updated description on retired contract") candidate.tags = ["legacy", "orders", "updated"] - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert decision.policy.retired_violation is True @@ -140,7 +140,7 @@ def test_retired_schema_change_blocks(self) -> None: ) ) - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert decision.policy.retired_violation is True @@ -159,7 +159,7 @@ def test_retired_property_change_blocks(self) -> None: SchemaProperty(name="customer_id", logicalType="string", required=False) ) - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert decision.policy.retired_violation is True @@ -183,7 +183,7 @@ def test_retired_quality_rule_change_blocks(self) -> None: ) ] - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert decision.policy.retired_violation is True @@ -206,7 +206,7 @@ def test_retired_relationship_change_blocks(self) -> None: ) ] - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert decision.policy.retired_violation is True @@ -223,7 +223,7 @@ def test_retired_custom_properties_change_blocks(self) -> None: CustomProperty(property="costCenter", value="finance") ] - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert decision.policy.retired_violation is True @@ -240,7 +240,7 @@ def test_retired_reactivation_blocks(self) -> None: candidate = _make_retired_contract() candidate.status = target_status - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert decision.policy.retired_violation is True @@ -255,7 +255,7 @@ def test_retired_version_change_blocks(self) -> None: candidate = _make_retired_contract() candidate.version = "2.0.0" - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert decision.policy.retired_violation is True @@ -269,7 +269,7 @@ def test_active_to_retired_transition_reviews_not_mutation(self) -> None: base = _make_active_contract() candidate = _make_retired_contract() - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.REVIEW assert decision.policy.retired_violation is False @@ -289,7 +289,7 @@ def test_draft_and_deprecated_to_retired_transition_reviews(self) -> None: base.status = start_status candidate = _make_retired_contract() - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.REVIEW assert decision.policy.retired_violation is False @@ -310,7 +310,7 @@ def test_retired_invalid_mutated_candidate_blocks_cleanly(self) -> None: candidate.status = "invalid_status_value" candidate.description = Description(usage="Mutated invalid contract") - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert decision.validation.valid is False @@ -361,7 +361,6 @@ def test_merge_command_blocks_retired_mutation(self, tmp_path: Path) -> None: business=str(base_path), output=str(out_path), runtime_context="auto", - effective_date="2026-08-14", ) with pytest.raises(GovernanceBlockedError) as exc_info: @@ -546,7 +545,6 @@ def test_analyze_unchanged_retired_allowed(self) -> None: decision = GovernanceService().evaluate( base_retired, candidate, - effective_date=date(2026, 8, 14), ) gate_res = evaluate_governance_gate(decision, GovernanceOperation.ANALYZE) From dc5d6462a5ebf600446ffeffee6af71dfdd05eb5 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:32:05 +1000 Subject: [PATCH 553/616] test(governance): remove date from pure analysis paths --- tests/test_fail_closed_governance_integration.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/test_fail_closed_governance_integration.py b/tests/test_fail_closed_governance_integration.py index 18472a31..6302fbc1 100644 --- a/tests/test_fail_closed_governance_integration.py +++ b/tests/test_fail_closed_governance_integration.py @@ -104,7 +104,7 @@ def test_injected_block_decision_prevents_side_effects(tmp_path, monkeypatch): ) monkeypatch.setattr("semapact.core.lifecycle_cli._apply_contract", lambda c, s: setattr(c, "version", "2.0.0")) with pytest.raises(GovernanceBlockedError): - apply_lifecycle(lifecycle_args, is_promote=True, context=TEST_CONTEXT) + apply_lifecycle(lifecycle_args, is_promote=True) assert not (tmp_path / "lifecycle_out.yaml").exists() @@ -291,7 +291,6 @@ def test_breaking_change_review_behavior(tmp_path, capsys): base=str(base_path), candidate=str(cand_path), runtime_context="auto", - effective_date=TEST_EFFECTIVE_DATE, ) class_res = run_release_classify(classify_args) assert class_res["requiredBump"] == "minor" @@ -307,12 +306,12 @@ def test_breaking_change_review_behavior(tmp_path, capsys): property="amount", ) with pytest.raises(GovernanceReviewRequiredError) as exc: - apply_lifecycle(lifecycle_args, is_promote=False, context=TEST_CONTEXT) + apply_lifecycle(lifecycle_args, is_promote=False) assert exc.value.operation == GovernanceOperation.APPLY assert "Governance decision REVIEW required" in str(exc.value) # 4. CI (evaluate_ci_gate & pipeline): Returns allowed=False / raises GovernanceReviewRequiredError - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) ci_res = evaluate_ci_gate(decision) assert ci_res.allowed is False assert ci_res.reason == "review_required" @@ -367,7 +366,7 @@ def test_prepare_ci_cd_artifacts_enforces_ci_gate_directly(tmp_path): from open_data_contract_standard.model import DataQuality candidate.schema_[0].properties[0].quality = [DataQuality(type="invalid_quality_type_xyz")] - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision.value == "BLOCK" merged_out = tmp_path / "direct_merged.yaml" @@ -397,7 +396,7 @@ def test_ci_cd_adapter_preserves_allowed_reason(): """Verify that evaluate_ci_gate returns 'allowed' reason when allowed is True.""" base = _make_contract(status="active") candidate = _make_contract(status="active") - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) ci_dec = evaluate_ci_gate(decision) assert ci_dec.allowed is True @@ -421,7 +420,6 @@ def test_classify_repo_sets_blocked_status(tmp_path): changes = classify_contracts_in_repo( base_root=str(base_dir), candidate_root=str(cand_dir), - context=TEST_CONTEXT, ) blocked_change = next(c for c in changes if c.contract_repo_path == "blocked.yaml") assert blocked_change.status == "blocked" From d95816f1a1e48ad22e5276b67edd596fd8e5c36b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:32:45 +1000 Subject: [PATCH 554/616] test(retired): keep effective date only on mutation commands --- tests/test_retired_immutability.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_retired_immutability.py b/tests/test_retired_immutability.py index 7787fa33..15220270 100644 --- a/tests/test_retired_immutability.py +++ b/tests/test_retired_immutability.py @@ -361,6 +361,7 @@ def test_merge_command_blocks_retired_mutation(self, tmp_path: Path) -> None: business=str(base_path), output=str(out_path), runtime_context="auto", + effective_date="2026-08-14", ) with pytest.raises(GovernanceBlockedError) as exc_info: @@ -565,7 +566,6 @@ def test_analyze_mutated_retired_returns_block_decision_without_error(self, tmp_ base=str(base_path), candidate=str(cand_path), runtime_context="auto", - effective_date="2026-08-14", ) result = run_release_classify(args) From 36177073ff85b3a91e96801cb461b211864a0150 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:33:01 +1000 Subject: [PATCH 555/616] test(cli): remove effective date from release classify subprocess cases --- tests/interfaces/test_cli_subprocess_outcomes.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/interfaces/test_cli_subprocess_outcomes.py b/tests/interfaces/test_cli_subprocess_outcomes.py index f8048ad1..a0e7f991 100644 --- a/tests/interfaces/test_cli_subprocess_outcomes.py +++ b/tests/interfaces/test_cli_subprocess_outcomes.py @@ -142,8 +142,6 @@ def test_subprocess_analyze_release_classify_allow(active_contract_yaml: Path): str(active_contract_yaml), "--candidate", str(active_contract_yaml), - "--effective-date", - "2026-08-29", ) assert res.returncode == 0 payload = json.loads(res.stdout) @@ -163,8 +161,6 @@ def test_subprocess_analyze_release_classify_review( str(active_contract_yaml), "--candidate", str(review_candidate_yaml), - "--effective-date", - "2026-08-29", ) assert res.returncode == 0 payload = json.loads(res.stdout) @@ -184,8 +180,6 @@ def test_subprocess_analyze_release_classify_block( str(retired_contract_yaml), "--candidate", str(review_candidate_yaml), - "--effective-date", - "2026-08-29", ) assert res.returncode == 0 payload = json.loads(res.stdout) @@ -293,8 +287,6 @@ def test_subprocess_validation_failed_on_invalid_contract( str(invalid_contract_yaml), "--candidate", str(active_contract_yaml), - "--effective-date", - "2026-08-29", ) assert res.returncode == 2 assert "❌" in res.stderr @@ -313,8 +305,6 @@ def test_subprocess_runtime_error_on_missing_file(active_contract_yaml: Path): "/nonexistent/path/contract.yaml", "--candidate", str(active_contract_yaml), - "--effective-date", - "2026-08-29", ) # File not found error is a runtime execution failure assert res.returncode == 5 From 3c78eee5ce058d0d13b3b0eb13f0cccf038fa677 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:33:23 +1000 Subject: [PATCH 556/616] test(lifecycle): remove effective context from pure governance evaluation From b4afbc5a48b4c384cac05d7b0ab08d8a8cdfa0ca Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:09 +1000 Subject: [PATCH 557/616] test(governance): replace legacy decision snapshots with semantic golden checks --- tests/test_governance_golden_scenarios.py | 40 ++++++++++++++--------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/tests/test_governance_golden_scenarios.py b/tests/test_governance_golden_scenarios.py index d467aae2..f21f690f 100644 --- a/tests/test_governance_golden_scenarios.py +++ b/tests/test_governance_golden_scenarios.py @@ -342,17 +342,14 @@ def _load_contract_from_yaml(yaml_path: Path) -> OpenDataContractStandard: @pytest.mark.parametrize("scenario", SCENARIO_MATRIX, ids=lambda s: s.name) def test_governance_golden_scenarios(scenario: GovernanceGoldenScenario) -> None: - """Evaluate golden scenario, verify domain invariants, and compare byte-exact public JSON.""" + """Evaluate each canonical governance scenario and verify deterministic public projection.""" scenario_dir = FIXTURES_DIR / scenario.name assert scenario_dir.exists(), f"Scenario directory missing: {scenario_dir}" base_path = scenario_dir / "base.yaml" cand_path = scenario_dir / "candidate.yaml" - expected_path = scenario_dir / "expected.json" - assert base_path.exists(), f"base.yaml missing for {scenario.name}" assert cand_path.exists(), f"candidate.yaml missing for {scenario.name}" - assert expected_path.exists(), f"expected.json missing for {scenario.name}" base_contract = _load_contract_from_yaml(base_path) cand_contract = _load_contract_from_yaml(cand_path) @@ -391,16 +388,21 @@ def test_governance_golden_scenarios(scenario: GovernanceGoldenScenario) -> None f"[{scenario.name}] validation.valid mismatch: got {decision.validation.valid}, expected {scenario.expected_validation_valid}" ) - # 3. Public projection and read-only byte-exact golden comparison (Phase 3 & 4) + # 3. Public projection remains deterministic and free of execution-time context. public_decision = to_public_governance_decision(decision) - actual_json = serialize_public_governance_decision(public_decision, indent=2) + "\n" - expected_json = expected_path.read_text(encoding="utf-8") - - assert actual_json == expected_json, ( - f"[{scenario.name}] Golden JSON mismatch:\n" - f"--- Actual ---\n{actual_json}\n" - f"--- Expected ---\n{expected_json}" + first_json = serialize_public_governance_decision(public_decision, indent=2) + second_json = serialize_public_governance_decision( + to_public_governance_decision( + evaluate_governance_decision( + base_contract, + cand_contract, + merge_conflicts=scenario.merge_conflicts, + ) + ), + indent=2, ) + assert first_json == second_json + assert '"context"' not in first_json # ============================================================================== @@ -665,9 +667,17 @@ def test_fixtures_are_read_only() -> None: b, c, merge_conflicts=scenario.merge_conflicts ) pub = to_public_governance_decision(d) - actual = serialize_public_governance_decision(pub, indent=2) + "\n" - expected = (s_dir / "expected.json").read_text(encoding="utf-8") - assert actual == expected + first = serialize_public_governance_decision(pub, indent=2) + second = serialize_public_governance_decision( + to_public_governance_decision( + evaluate_governance_decision( + b, c, merge_conflicts=scenario.merge_conflicts + ) + ), + indent=2, + ) + assert first == second + assert '"context"' not in first hashes_after: dict[str, str] = {} for f in FIXTURES_DIR.rglob("*"): From 828b6d31d6ba52557edc7e02413ffae683bbaac1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:13 +1000 Subject: [PATCH 558/616] test(governance): remove obsolete public decision snapshots --- tests/test_public_governance_decision.py | 54 ------------------------ 1 file changed, 54 deletions(-) diff --git a/tests/test_public_governance_decision.py b/tests/test_public_governance_decision.py index 63a480c9..f807084a 100644 --- a/tests/test_public_governance_decision.py +++ b/tests/test_public_governance_decision.py @@ -2,7 +2,6 @@ from __future__ import annotations -from pathlib import Path import pytest from pydantic import ValidationError as PydanticValidationError from open_data_contract_standard.model import ( @@ -26,8 +25,6 @@ ) -FIXTURES_DIR = Path(__file__).parent / "fixtures" / "governance_decisions" - def _get_schemas(contract: OpenDataContractStandard) -> list[SchemaObject]: return getattr(contract, "schema_", getattr(contract, "schema", [])) or [] @@ -418,54 +415,3 @@ def test_public_decision_roundtrip_deserialization(): assert restored.decision == "REVIEW" assert restored.schema_version == "1" - -def test_golden_fixtures_match(): - """Verify golden JSON fixtures match projected decisions with exact string equality (read-only assertion).""" - # 1. ALLOW clean - base = _make_contract() - allow_dec = evaluate_governance_decision(base, base) - allow_pub = to_public_governance_decision(allow_dec) - allow_json_str = serialize_public_governance_decision(allow_pub, indent=2) + "\n" - expected_allow = (FIXTURES_DIR / "allow_clean_decision.json").read_text(encoding="utf-8") - assert allow_json_str == expected_allow - - # 2. BREAKING review decimal - cand_breaking = _make_contract() - _get_schemas(cand_breaking)[0].properties[1].physicalType = "decimal(8,2)" - breaking_dec = evaluate_governance_decision(base, cand_breaking) - breaking_pub = to_public_governance_decision(breaking_dec) - breaking_json_str = serialize_public_governance_decision(breaking_pub, indent=2) + "\n" - expected_breaking = (FIXTURES_DIR / "breaking_review_decision.json").read_text(encoding="utf-8") - assert breaking_json_str == expected_breaking - - # 3. REVIEW deprecate property - cand_deprecate = _make_contract() - _get_schemas(cand_deprecate)[0].properties[1].customProperties = [ - CustomProperty(property="lifecycleStatus", value="deprecated") - ] - review_dec = evaluate_governance_decision(base, cand_deprecate) - review_pub = to_public_governance_decision(review_dec) - review_json_str = serialize_public_governance_decision(review_pub, indent=2) + "\n" - expected_review = (FIXTURES_DIR / "review_deprecate_decision.json").read_text(encoding="utf-8") - assert review_json_str == expected_review - - # 4. BLOCK retired contract - base_retired = _make_contract(status="retired") - cand_retired = _make_contract(status="retired") - _get_schemas(cand_retired)[0].properties[1].description = "Retired update" - retired_dec = evaluate_governance_decision(base_retired, cand_retired) - retired_pub = to_public_governance_decision(retired_dec) - retired_json_str = serialize_public_governance_decision(retired_pub, indent=2) + "\n" - expected_retired = (FIXTURES_DIR / "block_retired_decision.json").read_text(encoding="utf-8") - assert retired_json_str == expected_retired - - # 5. BLOCK invalid validation - cand_invalid = _make_contract() - _get_schemas(cand_invalid)[0].properties.append( - SchemaProperty(name="", logicalType="string", physicalType="", required=True) - ) - invalid_dec = evaluate_governance_decision(base, cand_invalid) - invalid_pub = to_public_governance_decision(invalid_dec) - invalid_json_str = serialize_public_governance_decision(invalid_pub, indent=2) + "\n" - expected_invalid = (FIXTURES_DIR / "block_validation_decision.json").read_text(encoding="utf-8") - assert invalid_json_str == expected_invalid From 855656598e9a07f66f4e2fe91139a1791069e3df Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:26 +1000 Subject: [PATCH 559/616] test(governance): remove obsolete dated decision snapshot --- .../allow_clean_decision.json | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 tests/fixtures/governance_decisions/allow_clean_decision.json diff --git a/tests/fixtures/governance_decisions/allow_clean_decision.json b/tests/fixtures/governance_decisions/allow_clean_decision.json deleted file mode 100644 index 9145b0b6..00000000 --- a/tests/fixtures/governance_decisions/allow_clean_decision.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "breaking": false, - "changes": [], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "my-data-contract", - "decision": "ALLOW", - "decisionId": "0480ed51-8b7f-57aa-93f2-b09c135004c1", - "evidence": { - "hasChanges": false, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "No contract changes detected", - "path": null, - "severity": "INFO" - } - ], - "requiredVersionBump": "none", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From da94c49a965d279649d06b7607e7ed5fade95f5f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:28 +1000 Subject: [PATCH 560/616] test(governance): remove obsolete dated decision snapshot --- .../block_retired_decision.json | 72 ------------------- 1 file changed, 72 deletions(-) delete mode 100644 tests/fixtures/governance_decisions/block_retired_decision.json diff --git a/tests/fixtures/governance_decisions/block_retired_decision.json b/tests/fixtures/governance_decisions/block_retired_decision.json deleted file mode 100644 index 4f2b4cb6..00000000 --- a/tests/fixtures/governance_decisions/block_retired_decision.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "breaking": false, - "changes": [ - { - "after": "Retired update", - "before": null, - "breaking": false, - "changeType": "MODIFY", - "domain": "METADATA", - "entityType": "PROPERTY", - "evidence": [], - "field": "description", - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount].description", - "reasonCodes": [] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "my-data-contract", - "decision": "BLOCK", - "decisionId": "e8296105-5b21-5c7b-b5f1-99004a632224", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": true, - "valid": false, - "versionViolation": false, - "violations": [ - { - "code": "RETIRED_CONTRACT_MODIFIED", - "details": {}, - "message": "Cannot modify a retired contract.", - "path": "status", - "severity": "ERROR" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "RETIRED_CONTRACT_MODIFIED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Only descriptive metadata changed; no required version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "RETIRED_CONTRACT_MODIFIED", - "details": {}, - "message": "Cannot modify a retired contract.", - "path": "status", - "severity": "ERROR" - } - ], - "requiredVersionBump": "none", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 41f7d2047ea7f08ac6b36d13be3abfa581ee3b49 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:31 +1000 Subject: [PATCH 561/616] test(governance): remove obsolete dated decision snapshot --- .../block_validation_decision.json | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 tests/fixtures/governance_decisions/block_validation_decision.json diff --git a/tests/fixtures/governance_decisions/block_validation_decision.json b/tests/fixtures/governance_decisions/block_validation_decision.json deleted file mode 100644 index 41f06a3a..00000000 --- a/tests/fixtures/governance_decisions/block_validation_decision.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "breaking": false, - "changes": [], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "my-data-contract", - "decision": "BLOCK", - "decisionId": "3de328dd-f6f1-5bff-b805-c3f1ff2325c4", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": false, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "VALIDATION_FAILED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "No contract changes detected", - "path": null, - "severity": "INFO" - }, - { - "code": "VALIDATION_FAILED", - "details": {}, - "message": "Property name cannot be empty or whitespace-only", - "path": "schema", - "severity": "ERROR" - } - ], - "requiredVersionBump": "none", - "schemaVersion": "1", - "validation": { - "issues": [ - { - "code": "VALIDATION_FAILED", - "details": {}, - "message": "Property name cannot be empty or whitespace-only", - "path": "schema", - "severity": "ERROR" - } - ], - "valid": false - } -} From de3160eab7844bea993cb60cc150518602646c46 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:34 +1000 Subject: [PATCH 562/616] test(governance): remove obsolete dated decision snapshot --- .../breaking_review_decision.json | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 tests/fixtures/governance_decisions/breaking_review_decision.json diff --git a/tests/fixtures/governance_decisions/breaking_review_decision.json b/tests/fixtures/governance_decisions/breaking_review_decision.json deleted file mode 100644 index bda1b129..00000000 --- a/tests/fixtures/governance_decisions/breaking_review_decision.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "breaking": true, - "changes": [ - { - "after": "decimal(8,2)", - "before": "decimal(10,2)", - "breaking": true, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "physicalType", - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount].physicalType", - "reasonCodes": [ - "DECIMAL_PRECISION_REDUCED" - ] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "my-data-contract", - "decision": "REVIEW", - "decisionId": "dc341a61-b19f-5c01-a250-0fda5ea7d061", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": false, - "versionViolation": false, - "violations": [ - { - "code": "DECIMAL_PRECISION_REDUCED", - "details": {}, - "message": "Decimal precision reduced from 'decimal(10,2)' to 'decimal(8,2)'", - "path": "schema[orders].properties[amount].physicalType", - "severity": "WARNING" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "DECIMAL_PRECISION_REDUCED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Breaking lifecycle changes require a major version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "DECIMAL_PRECISION_REDUCED", - "details": {}, - "message": "Decimal precision reduced from 'decimal(10,2)' to 'decimal(8,2)'", - "path": "schema[orders].properties[amount].physicalType", - "severity": "WARNING" - } - ], - "requiredVersionBump": "major", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 5471ffd1bbe94dda16a110a4624b7e45c5a8c2f1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:36 +1000 Subject: [PATCH 563/616] test(governance): remove obsolete dated decision snapshot --- .../review_deprecate_decision.json | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 tests/fixtures/governance_decisions/review_deprecate_decision.json diff --git a/tests/fixtures/governance_decisions/review_deprecate_decision.json b/tests/fixtures/governance_decisions/review_deprecate_decision.json deleted file mode 100644 index 6d040922..00000000 --- a/tests/fixtures/governance_decisions/review_deprecate_decision.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "breaking": false, - "changes": [ - { - "after": "deprecated", - "before": null, - "breaking": false, - "changeType": "DEPRECATE", - "domain": "LIFECYCLE", - "entityType": "PROPERTY", - "evidence": [], - "field": "lifecycleStatus", - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount]", - "reasonCodes": [] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "my-data-contract", - "decision": "REVIEW", - "decisionId": "43daa6d0-6925-5b00-ad33-18e871dadfa9", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "New schema/property deprecations require a minor version bump", - "path": null, - "severity": "INFO" - } - ], - "requiredVersionBump": "minor", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 77cddbfd254c4e69fde2933b754be023d311de2f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:39 +1000 Subject: [PATCH 564/616] test(governance): remove obsolete dated decision snapshot --- .../expected.json | 71 ------------------- 1 file changed, 71 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/active_to_retired_transition/expected.json diff --git a/tests/fixtures/governance_scenarios/active_to_retired_transition/expected.json b/tests/fixtures/governance_scenarios/active_to_retired_transition/expected.json deleted file mode 100644 index d800c96c..00000000 --- a/tests/fixtures/governance_scenarios/active_to_retired_transition/expected.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "breaking": false, - "changes": [ - { - "after": "retired", - "before": "active", - "breaking": false, - "changeType": "DEPRECATE", - "domain": "LIFECYCLE", - "entityType": "CONTRACT", - "evidence": [], - "field": "status", - "identity": [ - "orders-contract" - ], - "path": "status", - "reasonCodes": [] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "b18506a1-f428-510f-9c5b-848054f2b03f", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [ - { - "code": "CONTRACT_RETIRED_TRANSITION", - "details": {}, - "message": "Contract transition to retired status requires governance review.", - "path": "status", - "severity": "WARNING" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "CONTRACT_RETIRED_TRANSITION" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Only descriptive metadata changed; no required version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "CONTRACT_RETIRED_TRANSITION", - "details": {}, - "message": "Contract transition to retired status requires governance review.", - "path": "status", - "severity": "WARNING" - } - ], - "requiredVersionBump": "none", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From c6b667b0aa6dc5ab73ea0b7ba2515daea8318b2a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:41 +1000 Subject: [PATCH 565/616] test(governance): remove obsolete dated decision snapshot --- .../contract_id_change/expected.json | 73 ------------------- 1 file changed, 73 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/contract_id_change/expected.json diff --git a/tests/fixtures/governance_scenarios/contract_id_change/expected.json b/tests/fixtures/governance_scenarios/contract_id_change/expected.json deleted file mode 100644 index 60ce02bc..00000000 --- a/tests/fixtures/governance_scenarios/contract_id_change/expected.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "breaking": true, - "changes": [ - { - "after": "renamed-orders-contract", - "before": "orders-contract", - "breaking": true, - "changeType": "MODIFY", - "domain": "IDENTITY", - "entityType": "CONTRACT", - "evidence": [], - "field": "id", - "identity": [ - "orders-contract" - ], - "path": "id", - "reasonCodes": [ - "CONTRACT_ID_CHANGED" - ] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "BLOCK", - "decisionId": "750bd41f-1b63-5de6-a47c-47d9515c4d10", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": true, - "retiredViolation": false, - "valid": false, - "versionViolation": false, - "violations": [ - { - "code": "CONTRACT_ID_CHANGED", - "details": {}, - "message": "Contract ID mismatch. You changed the root ID of the contract, which is immutable. If you want to create a new contract, use 'semapact import --new' or change the ID back.", - "path": "id", - "severity": "ERROR" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "CONTRACT_ID_CHANGED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Breaking lifecycle changes require a major version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "CONTRACT_ID_CHANGED", - "details": {}, - "message": "Contract ID mismatch. You changed the root ID of the contract, which is immutable. If you want to create a new contract, use 'semapact import --new' or change the ID back.", - "path": "id", - "severity": "ERROR" - } - ], - "requiredVersionBump": "major", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 45e72a9d7e754809ec7ccb3827b8df48d534ccee Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:44 +1000 Subject: [PATCH 566/616] test(governance): remove obsolete dated decision snapshot --- .../decimal_precision_reduction/expected.json | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/decimal_precision_reduction/expected.json diff --git a/tests/fixtures/governance_scenarios/decimal_precision_reduction/expected.json b/tests/fixtures/governance_scenarios/decimal_precision_reduction/expected.json deleted file mode 100644 index ab4d9b7b..00000000 --- a/tests/fixtures/governance_scenarios/decimal_precision_reduction/expected.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "breaking": true, - "changes": [ - { - "after": "decimal(8,2)", - "before": "decimal(10,2)", - "breaking": true, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "physicalType", - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount].physicalType", - "reasonCodes": [ - "DECIMAL_PRECISION_REDUCED" - ] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "2300dfb4-71cc-5601-8b7c-eeb1cdad2306", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": false, - "versionViolation": false, - "violations": [ - { - "code": "DECIMAL_PRECISION_REDUCED", - "details": {}, - "message": "Decimal precision reduced from 'decimal(10,2)' to 'decimal(8,2)'", - "path": "schema[orders].properties[amount].physicalType", - "severity": "WARNING" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "DECIMAL_PRECISION_REDUCED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Breaking lifecycle changes require a major version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "DECIMAL_PRECISION_REDUCED", - "details": {}, - "message": "Decimal precision reduced from 'decimal(10,2)' to 'decimal(8,2)'", - "path": "schema[orders].properties[amount].physicalType", - "severity": "WARNING" - } - ], - "requiredVersionBump": "major", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 27ab488a3748ebeba7cc6b83301d1328855b932e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:46 +1000 Subject: [PATCH 567/616] test(governance): remove obsolete dated decision snapshot --- .../decimal_scale_reduction/expected.json | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/decimal_scale_reduction/expected.json diff --git a/tests/fixtures/governance_scenarios/decimal_scale_reduction/expected.json b/tests/fixtures/governance_scenarios/decimal_scale_reduction/expected.json deleted file mode 100644 index 9e26be98..00000000 --- a/tests/fixtures/governance_scenarios/decimal_scale_reduction/expected.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "breaking": true, - "changes": [ - { - "after": "decimal(10,2)", - "before": "decimal(10,4)", - "breaking": true, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "physicalType", - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount].physicalType", - "reasonCodes": [ - "DECIMAL_SCALE_REDUCED" - ] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "d0c6689c-2f79-5a9f-999d-6b1a2e75fefc", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": false, - "versionViolation": false, - "violations": [ - { - "code": "DECIMAL_SCALE_REDUCED", - "details": {}, - "message": "Decimal scale reduced from 'decimal(10,4)' to 'decimal(10,2)'", - "path": "schema[orders].properties[amount].physicalType", - "severity": "WARNING" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "DECIMAL_SCALE_REDUCED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Breaking lifecycle changes require a major version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "DECIMAL_SCALE_REDUCED", - "details": {}, - "message": "Decimal scale reduced from 'decimal(10,4)' to 'decimal(10,2)'", - "path": "schema[orders].properties[amount].physicalType", - "severity": "WARNING" - } - ], - "requiredVersionBump": "major", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 2a6b2acdb3543e441a51427952820ec4b2388b3f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:49 +1000 Subject: [PATCH 568/616] test(governance): remove obsolete dated decision snapshot --- .../decimal_widening/expected.json | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/decimal_widening/expected.json diff --git a/tests/fixtures/governance_scenarios/decimal_widening/expected.json b/tests/fixtures/governance_scenarios/decimal_widening/expected.json deleted file mode 100644 index 92a01c8d..00000000 --- a/tests/fixtures/governance_scenarios/decimal_widening/expected.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "breaking": false, - "changes": [ - { - "after": "decimal(14,4)", - "before": "decimal(10,2)", - "breaking": false, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "physicalType", - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount].physicalType", - "reasonCodes": [] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "04ce4d23-79dd-54cd-ae92-c7e65744412c", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Non-breaking structural or quality changes require a minor version bump", - "path": null, - "severity": "INFO" - } - ], - "requiredVersionBump": "minor", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 37a70c39f0e33633f612aa653d05b139381d739d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:51 +1000 Subject: [PATCH 569/616] test(governance): remove obsolete dated decision snapshot --- .../deprecated_entity_change/expected.json | 88 ------------------- 1 file changed, 88 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/deprecated_entity_change/expected.json diff --git a/tests/fixtures/governance_scenarios/deprecated_entity_change/expected.json b/tests/fixtures/governance_scenarios/deprecated_entity_change/expected.json deleted file mode 100644 index 0bdb8660..00000000 --- a/tests/fixtures/governance_scenarios/deprecated_entity_change/expected.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "breaking": false, - "changes": [ - { - "after": "integer", - "before": "string", - "breaking": false, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "logicalType", - "identity": [ - "orders", - "legacy_code" - ], - "path": "schema[orders].properties[legacy_code].logicalType", - "reasonCodes": [] - }, - { - "after": "bigint", - "before": "varchar(255)", - "breaking": false, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "physicalType", - "identity": [ - "orders", - "legacy_code" - ], - "path": "schema[orders].properties[legacy_code].physicalType", - "reasonCodes": [] - }, - { - "after": true, - "before": false, - "breaking": false, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "required", - "identity": [ - "orders", - "legacy_code" - ], - "path": "schema[orders].properties[legacy_code].required", - "reasonCodes": [] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "17fec941-afae-54ce-9764-4d2efab07418", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Non-breaking structural or quality changes require a minor version bump", - "path": null, - "severity": "INFO" - } - ], - "requiredVersionBump": "minor", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 76632c12aa386374a92785e48ceda8eedc44cd06 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:54 +1000 Subject: [PATCH 570/616] test(governance): remove obsolete dated decision snapshot --- .../descriptive_metadata_only/expected.json | 106 ------------------ 1 file changed, 106 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/descriptive_metadata_only/expected.json diff --git a/tests/fixtures/governance_scenarios/descriptive_metadata_only/expected.json b/tests/fixtures/governance_scenarios/descriptive_metadata_only/expected.json deleted file mode 100644 index bc8e2c3f..00000000 --- a/tests/fixtures/governance_scenarios/descriptive_metadata_only/expected.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "breaking": false, - "changes": [ - { - "after": "Orders table description update", - "before": "Orders table description", - "breaking": false, - "changeType": "MODIFY", - "domain": "METADATA", - "entityType": "SCHEMA", - "evidence": [], - "field": "description", - "identity": [ - "orders" - ], - "path": "schema[orders].description", - "reasonCodes": [] - }, - { - "after": "Order total amount in USD", - "before": "Order total amount", - "breaking": false, - "changeType": "MODIFY", - "domain": "METADATA", - "entityType": "PROPERTY", - "evidence": [], - "field": "description", - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount].description", - "reasonCodes": [] - }, - { - "after": "Primary identifier updated notes", - "before": "Primary identifier", - "breaking": false, - "changeType": "MODIFY", - "domain": "METADATA", - "entityType": "PROPERTY", - "evidence": [], - "field": "description", - "identity": [ - "orders", - "id" - ], - "path": "schema[orders].properties[id].description", - "reasonCodes": [] - }, - { - "after": { - "purpose": "Updated descriptive documentation for orders contract" - }, - "before": { - "purpose": "Authoritative orders contract" - }, - "breaking": false, - "changeType": "MODIFY", - "domain": "METADATA", - "entityType": "CONTRACT", - "evidence": [], - "field": "description", - "identity": [ - "orders-contract" - ], - "path": "description", - "reasonCodes": [] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "ALLOW", - "decisionId": "0a749876-f87f-5348-8b7b-4694ffd3aba9", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Only descriptive metadata changed; no required version bump", - "path": null, - "severity": "INFO" - } - ], - "requiredVersionBump": "none", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From a532e101d6ca13b5710d709684cc00c1430598e7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:56 +1000 Subject: [PATCH 571/616] test(governance): remove obsolete dated decision snapshot --- .../draft_entity_change/expected.json | 88 ------------------- 1 file changed, 88 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/draft_entity_change/expected.json diff --git a/tests/fixtures/governance_scenarios/draft_entity_change/expected.json b/tests/fixtures/governance_scenarios/draft_entity_change/expected.json deleted file mode 100644 index 1a3dbf87..00000000 --- a/tests/fixtures/governance_scenarios/draft_entity_change/expected.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "breaking": false, - "changes": [ - { - "after": "integer", - "before": "string", - "breaking": false, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "logicalType", - "identity": [ - "orders", - "experimental_feature" - ], - "path": "schema[orders].properties[experimental_feature].logicalType", - "reasonCodes": [] - }, - { - "after": "bigint", - "before": "varchar(255)", - "breaking": false, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "physicalType", - "identity": [ - "orders", - "experimental_feature" - ], - "path": "schema[orders].properties[experimental_feature].physicalType", - "reasonCodes": [] - }, - { - "after": true, - "before": false, - "breaking": false, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "required", - "identity": [ - "orders", - "experimental_feature" - ], - "path": "schema[orders].properties[experimental_feature].required", - "reasonCodes": [] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "87f536c6-c55c-5111-b573-366b5a5fe71a", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Non-breaking structural or quality changes require a minor version bump", - "path": null, - "severity": "INFO" - } - ], - "requiredVersionBump": "minor", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 52842b11125d39f8e1aac9bd988f4bd448a00756 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:34:59 +1000 Subject: [PATCH 572/616] test(governance): remove obsolete dated decision snapshot --- .../enum_reduction/expected.json | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/enum_reduction/expected.json diff --git a/tests/fixtures/governance_scenarios/enum_reduction/expected.json b/tests/fixtures/governance_scenarios/enum_reduction/expected.json deleted file mode 100644 index 1b17e053..00000000 --- a/tests/fixtures/governance_scenarios/enum_reduction/expected.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "breaking": false, - "changes": [], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "ALLOW", - "decisionId": "59d98075-e496-52c7-9a72-6045ccb21b0d", - "evidence": { - "hasChanges": false, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "No contract changes detected", - "path": null, - "severity": "INFO" - } - ], - "requiredVersionBump": "none", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 6eae9cc84732f2751f31c5fc3cadf01f6dd8ce78 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:13 +1000 Subject: [PATCH 573/616] test(governance): remove obsolete dated decision snapshot --- .../logical_type_change/expected.json | 90 ------------------- 1 file changed, 90 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/logical_type_change/expected.json diff --git a/tests/fixtures/governance_scenarios/logical_type_change/expected.json b/tests/fixtures/governance_scenarios/logical_type_change/expected.json deleted file mode 100644 index d967e1b8..00000000 --- a/tests/fixtures/governance_scenarios/logical_type_change/expected.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "breaking": true, - "changes": [ - { - "after": "string", - "before": "number", - "breaking": true, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "logicalType", - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount].logicalType", - "reasonCodes": [ - "LOGICAL_TYPE_CHANGED" - ] - }, - { - "after": "varchar(64)", - "before": "decimal(10,2)", - "breaking": false, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "physicalType", - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount].physicalType", - "reasonCodes": [] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "106e6a6f-aee7-500e-bc67-1da9acba847b", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": false, - "versionViolation": false, - "violations": [ - { - "code": "LOGICAL_TYPE_CHANGED", - "details": {}, - "message": "Logical type changed from 'number' to 'string'", - "path": "schema[orders].properties[amount].logicalType", - "severity": "WARNING" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "LOGICAL_TYPE_CHANGED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Breaking lifecycle changes require a major version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "LOGICAL_TYPE_CHANGED", - "details": {}, - "message": "Logical type changed from 'number' to 'string'", - "path": "schema[orders].properties[amount].logicalType", - "severity": "WARNING" - } - ], - "requiredVersionBump": "major", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 5651dc308f6ca3ad272b81bf284bcab881051700 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:16 +1000 Subject: [PATCH 574/616] test(governance): remove obsolete dated decision snapshot --- .../manual_version_change/expected.json | 73 ------------------- 1 file changed, 73 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/manual_version_change/expected.json diff --git a/tests/fixtures/governance_scenarios/manual_version_change/expected.json b/tests/fixtures/governance_scenarios/manual_version_change/expected.json deleted file mode 100644 index ace47285..00000000 --- a/tests/fixtures/governance_scenarios/manual_version_change/expected.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "breaking": true, - "changes": [ - { - "after": "2.0.0", - "before": "1.0.0", - "breaking": true, - "changeType": "MODIFY", - "domain": "VERSION", - "entityType": "CONTRACT", - "evidence": [], - "field": "version", - "identity": [ - "orders-contract" - ], - "path": "version", - "reasonCodes": [ - "CONTRACT_VERSION_MANUALLY_CHANGED" - ] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "BLOCK", - "decisionId": "642819bc-96eb-551c-abd0-ed2ba0187d27", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": false, - "versionViolation": true, - "violations": [ - { - "code": "CONTRACT_VERSION_MANUALLY_CHANGED", - "details": {}, - "message": "Contract version mismatch. Contract versions are release-managed and cannot be manually updated during normal import/merge. Please revert the version change; SemaPact sets the release version during 'semapact release finalize'.", - "path": "version", - "severity": "ERROR" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "CONTRACT_VERSION_MANUALLY_CHANGED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Breaking lifecycle changes require a major version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "CONTRACT_VERSION_MANUALLY_CHANGED", - "details": {}, - "message": "Contract version mismatch. Contract versions are release-managed and cannot be manually updated during normal import/merge. Please revert the version change; SemaPact sets the release version during 'semapact release finalize'.", - "path": "version", - "severity": "ERROR" - } - ], - "requiredVersionBump": "major", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 5a8cadac0979c4755cd68a5730432d44ab657783 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:18 +1000 Subject: [PATCH 575/616] test(governance): remove obsolete dated decision snapshot --- .../merge_conflict/expected.json | 51 ------------------- 1 file changed, 51 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/merge_conflict/expected.json diff --git a/tests/fixtures/governance_scenarios/merge_conflict/expected.json b/tests/fixtures/governance_scenarios/merge_conflict/expected.json deleted file mode 100644 index c9a2591d..00000000 --- a/tests/fixtures/governance_scenarios/merge_conflict/expected.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "breaking": false, - "changes": [], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "1678a050-8f17-5739-bfb2-83ab7aea05ad", - "evidence": { - "hasChanges": false, - "mergeConflictsCount": 1 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "MERGE_CONFLICT" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "No contract changes detected", - "path": null, - "severity": "INFO" - }, - { - "code": "MERGE_CONFLICT", - "details": { - "property_name": "amount", - "rule": "description_conflict", - "schema_id": "orders" - }, - "message": "Conflicting description updates from upstream branches", - "path": "schema[orders].properties[amount]", - "severity": "WARNING" - } - ], - "requiredVersionBump": "none", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 51e08925a30b258f826c1233bf006338c417444e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:21 +1000 Subject: [PATCH 576/616] test(governance): remove obsolete dated decision snapshot --- .../no_change/expected.json | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/no_change/expected.json diff --git a/tests/fixtures/governance_scenarios/no_change/expected.json b/tests/fixtures/governance_scenarios/no_change/expected.json deleted file mode 100644 index 3c5908c0..00000000 --- a/tests/fixtures/governance_scenarios/no_change/expected.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "breaking": false, - "changes": [], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "ALLOW", - "decisionId": "e496a148-d48f-528f-b007-3a9dd1d7f060", - "evidence": { - "hasChanges": false, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "No contract changes detected", - "path": null, - "severity": "INFO" - } - ], - "requiredVersionBump": "none", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 106562937bfb9e59b3b970ea822e668b57313c95 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:23 +1000 Subject: [PATCH 577/616] test(governance): remove obsolete dated decision snapshot --- .../expected.json | 71 ------------------- 1 file changed, 71 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/physical_name_identity_stability/expected.json diff --git a/tests/fixtures/governance_scenarios/physical_name_identity_stability/expected.json b/tests/fixtures/governance_scenarios/physical_name_identity_stability/expected.json deleted file mode 100644 index d6ebcbbe..00000000 --- a/tests/fixtures/governance_scenarios/physical_name_identity_stability/expected.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "breaking": false, - "changes": [ - { - "after": "tbl_orders_renamed_in_db", - "before": "tbl_orders", - "breaking": false, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "SCHEMA", - "evidence": [], - "field": "physicalName", - "identity": [ - "orders" - ], - "path": "schema[orders].physicalName", - "reasonCodes": [] - }, - { - "after": "col_id_renamed_in_db", - "before": "col_id", - "breaking": false, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "physicalName", - "identity": [ - "orders", - "id" - ], - "path": "schema[orders].properties[id].physicalName", - "reasonCodes": [] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "a9b4d271-2c80-56db-a119-c87110dc40f4", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Non-breaking structural or quality changes require a minor version bump", - "path": null, - "severity": "INFO" - } - ], - "requiredVersionBump": "minor", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 8b7cf122dc299af0ced8279d13c4f94d256f49ed Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:26 +1000 Subject: [PATCH 578/616] test(governance): remove obsolete dated decision snapshot --- .../physical_type_narrowing/expected.json | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/physical_type_narrowing/expected.json diff --git a/tests/fixtures/governance_scenarios/physical_type_narrowing/expected.json b/tests/fixtures/governance_scenarios/physical_type_narrowing/expected.json deleted file mode 100644 index 735f108a..00000000 --- a/tests/fixtures/governance_scenarios/physical_type_narrowing/expected.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "breaking": true, - "changes": [ - { - "after": "varchar(32)", - "before": "varchar(64)", - "breaking": true, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "physicalType", - "identity": [ - "orders", - "id" - ], - "path": "schema[orders].properties[id].physicalType", - "reasonCodes": [ - "PHYSICAL_TYPE_NARROWED" - ] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "4f94025b-d059-5dd6-a97f-80a2f124761b", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": false, - "versionViolation": false, - "violations": [ - { - "code": "PHYSICAL_TYPE_NARROWED", - "details": {}, - "message": "Physical type narrowed from 'varchar(64)' to 'varchar(32)'", - "path": "schema[orders].properties[id].physicalType", - "severity": "WARNING" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "PHYSICAL_TYPE_NARROWED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Breaking lifecycle changes require a major version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "PHYSICAL_TYPE_NARROWED", - "details": {}, - "message": "Physical type narrowed from 'varchar(64)' to 'varchar(32)'", - "path": "schema[orders].properties[id].physicalType", - "severity": "WARNING" - } - ], - "requiredVersionBump": "major", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 87898e54b30e6fc1322164990e0c92677d280c6c Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:28 +1000 Subject: [PATCH 579/616] test(governance): remove obsolete dated decision snapshot --- .../property_addition/expected.json | 71 ------------------- 1 file changed, 71 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/property_addition/expected.json diff --git a/tests/fixtures/governance_scenarios/property_addition/expected.json b/tests/fixtures/governance_scenarios/property_addition/expected.json deleted file mode 100644 index 1cfada20..00000000 --- a/tests/fixtures/governance_scenarios/property_addition/expected.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "breaking": false, - "changes": [ - { - "after": { - "description": "Foreign reference to customer", - "id": "customer_id", - "logicalType": "string", - "name": "customer_id", - "physicalName": "col_customer_id", - "physicalType": "varchar(64)", - "required": false - }, - "before": null, - "breaking": false, - "changeType": "ADD", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": null, - "identity": [ - "orders", - "customer_id" - ], - "path": "schema[orders].properties[customer_id]", - "reasonCodes": [] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "6aca9196-551a-52f4-a814-e1a86f6ba3f3", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Non-breaking structural or quality changes require a minor version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Schema or property additions require a minor version bump", - "path": null, - "severity": "INFO" - } - ], - "requiredVersionBump": "minor", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 5ac1cb94db54ce6c7a5cda9027b804b4b3885ed8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:32 +1000 Subject: [PATCH 580/616] test(governance): remove obsolete dated decision snapshot --- .../property_removal/expected.json | 82 ------------------- 1 file changed, 82 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/property_removal/expected.json diff --git a/tests/fixtures/governance_scenarios/property_removal/expected.json b/tests/fixtures/governance_scenarios/property_removal/expected.json deleted file mode 100644 index b12a3400..00000000 --- a/tests/fixtures/governance_scenarios/property_removal/expected.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "breaking": true, - "changes": [ - { - "after": null, - "before": { - "description": "Order total amount", - "id": "amount", - "logicalType": "number", - "name": "amount", - "physicalName": "col_amount", - "physicalType": "decimal(10,2)", - "required": false - }, - "breaking": true, - "changeType": "REMOVE", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": null, - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount]", - "reasonCodes": [ - "PROPERTY_REMOVED" - ] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "2ac271d9-2c86-578a-aafb-394ce63f3a4b", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": false, - "versionViolation": false, - "violations": [ - { - "code": "PROPERTY_REMOVED", - "details": {}, - "message": "Property removed from active lifecycle scope", - "path": "schema[orders].properties[amount]", - "severity": "WARNING" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "PROPERTY_REMOVED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Breaking lifecycle changes require a major version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "PROPERTY_REMOVED", - "details": {}, - "message": "Property removed from active lifecycle scope", - "path": "schema[orders].properties[amount]", - "severity": "WARNING" - } - ], - "requiredVersionBump": "major", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From d26279b9c2c0694e19c37ddc75493e67150c29e2 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:34 +1000 Subject: [PATCH 581/616] test(governance): remove obsolete dated decision snapshot --- .../relationship_removal/expected.json | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/relationship_removal/expected.json diff --git a/tests/fixtures/governance_scenarios/relationship_removal/expected.json b/tests/fixtures/governance_scenarios/relationship_removal/expected.json deleted file mode 100644 index 453a76df..00000000 --- a/tests/fixtures/governance_scenarios/relationship_removal/expected.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "breaking": true, - "changes": [ - { - "after": null, - "before": { - "to": "customers.id", - "type": "foreignKey" - }, - "breaking": true, - "changeType": "REMOVE", - "domain": "RELATIONSHIP", - "entityType": "RELATIONSHIP", - "evidence": [], - "field": null, - "identity": [ - "orders", - "foreignKey:->customers.id" - ], - "path": "schema[orders].relationships", - "reasonCodes": [ - "RELATIONSHIP_REMOVED" - ] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "1c53c03d-c05c-5416-9406-63703c8d5594", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": false, - "versionViolation": false, - "violations": [ - { - "code": "RELATIONSHIP_REMOVED", - "details": {}, - "message": "Relationship 'foreignKey:->customers.id' removed from active lifecycle scope. Downstream joins may fail.", - "path": "schema[orders].relationships", - "severity": "WARNING" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "RELATIONSHIP_REMOVED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Breaking lifecycle changes require a major version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "RELATIONSHIP_REMOVED", - "details": {}, - "message": "Relationship 'foreignKey:->customers.id' removed from active lifecycle scope. Downstream joins may fail.", - "path": "schema[orders].relationships", - "severity": "WARNING" - } - ], - "requiredVersionBump": "major", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 059a8d3d85add09bfcd9c1202060e55b4bb49f80 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:37 +1000 Subject: [PATCH 582/616] test(governance): remove obsolete dated decision snapshot --- .../required_tightening/expected.json | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/required_tightening/expected.json diff --git a/tests/fixtures/governance_scenarios/required_tightening/expected.json b/tests/fixtures/governance_scenarios/required_tightening/expected.json deleted file mode 100644 index 8fe443c4..00000000 --- a/tests/fixtures/governance_scenarios/required_tightening/expected.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "breaking": true, - "changes": [ - { - "after": true, - "before": false, - "breaking": true, - "changeType": "MODIFY", - "domain": "STRUCTURE", - "entityType": "PROPERTY", - "evidence": [], - "field": "required", - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount].required", - "reasonCodes": [ - "REQUIRED_TIGHTENED" - ] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "4ea6efa1-1081-56c1-8b31-a2645a4feabc", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": false, - "versionViolation": false, - "violations": [ - { - "code": "REQUIRED_TIGHTENED", - "details": {}, - "message": "Required flag tightened from False to True", - "path": "schema[orders].properties[amount].required", - "severity": "WARNING" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "REQUIRED_TIGHTENED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Breaking lifecycle changes require a major version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "REQUIRED_TIGHTENED", - "details": {}, - "message": "Required flag tightened from False to True", - "path": "schema[orders].properties[amount].required", - "severity": "WARNING" - } - ], - "requiredVersionBump": "major", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From 5ae5a1550135ef7cba67384963cf5dcfda28c103 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:39 +1000 Subject: [PATCH 583/616] test(governance): remove obsolete dated decision snapshot --- .../retired_contract_mutation/expected.json | 75 ------------------- 1 file changed, 75 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/retired_contract_mutation/expected.json diff --git a/tests/fixtures/governance_scenarios/retired_contract_mutation/expected.json b/tests/fixtures/governance_scenarios/retired_contract_mutation/expected.json deleted file mode 100644 index 990a5539..00000000 --- a/tests/fixtures/governance_scenarios/retired_contract_mutation/expected.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "breaking": false, - "changes": [ - { - "after": { - "purpose": "Retired orders contract - forbidden modification" - }, - "before": { - "purpose": "Retired orders contract" - }, - "breaking": false, - "changeType": "MODIFY", - "domain": "METADATA", - "entityType": "CONTRACT", - "evidence": [], - "field": "description", - "identity": [ - "orders-contract" - ], - "path": "description", - "reasonCodes": [] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "BLOCK", - "decisionId": "cbc08d31-312b-548a-9619-51dddd337642", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": true, - "valid": false, - "versionViolation": false, - "violations": [ - { - "code": "RETIRED_CONTRACT_MODIFIED", - "details": {}, - "message": "Cannot modify a retired contract.", - "path": "status", - "severity": "ERROR" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "RETIRED_CONTRACT_MODIFIED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Only descriptive metadata changed; no required version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "RETIRED_CONTRACT_MODIFIED", - "details": {}, - "message": "Cannot modify a retired contract.", - "path": "status", - "severity": "ERROR" - } - ], - "requiredVersionBump": "none", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From deccf9dba00e8a8335aa6218fec8f89b055f3fb0 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:43 +1000 Subject: [PATCH 584/616] test(governance): remove obsolete dated decision snapshot --- .../schema_addition/expected.json | 78 ------------------- 1 file changed, 78 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/schema_addition/expected.json diff --git a/tests/fixtures/governance_scenarios/schema_addition/expected.json b/tests/fixtures/governance_scenarios/schema_addition/expected.json deleted file mode 100644 index 7f1827b1..00000000 --- a/tests/fixtures/governance_scenarios/schema_addition/expected.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "breaking": false, - "changes": [ - { - "after": { - "description": "Line items table", - "id": "line_items", - "name": "line_items", - "physicalName": "tbl_line_items", - "properties": [ - { - "description": "Line item identifier", - "id": "item_id", - "logicalType": "string", - "name": "item_id", - "physicalName": "col_item_id", - "physicalType": "varchar(64)", - "required": true - } - ] - }, - "before": null, - "breaking": false, - "changeType": "ADD", - "domain": "STRUCTURE", - "entityType": "SCHEMA", - "evidence": [], - "field": null, - "identity": [ - "line_items" - ], - "path": "schema[line_items]", - "reasonCodes": [] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "54299462-9f11-563c-a152-f70551d6c5ea", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": true, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Non-breaking structural or quality changes require a minor version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Schema or property additions require a minor version bump", - "path": null, - "severity": "INFO" - } - ], - "requiredVersionBump": "minor", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From d23d19c5dcbc8234dca43bcb597eb6dc084ec912 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:45 +1000 Subject: [PATCH 585/616] test(governance): remove obsolete dated decision snapshot --- .../schema_removal/expected.json | 87 ------------------- 1 file changed, 87 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/schema_removal/expected.json diff --git a/tests/fixtures/governance_scenarios/schema_removal/expected.json b/tests/fixtures/governance_scenarios/schema_removal/expected.json deleted file mode 100644 index d3151205..00000000 --- a/tests/fixtures/governance_scenarios/schema_removal/expected.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "breaking": true, - "changes": [ - { - "after": null, - "before": { - "id": "audit_log", - "name": "audit_log", - "physicalName": "tbl_audit_log", - "properties": [ - { - "id": "log_id", - "logicalType": "string", - "name": "log_id", - "physicalName": "col_log_id", - "physicalType": "varchar(64)", - "required": true - } - ] - }, - "breaking": true, - "changeType": "REMOVE", - "domain": "STRUCTURE", - "entityType": "SCHEMA", - "evidence": [], - "field": null, - "identity": [ - "audit_log" - ], - "path": "schema[audit_log]", - "reasonCodes": [ - "SCHEMA_REMOVED" - ] - } - ], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "REVIEW", - "decisionId": "e3aeba92-dbbf-5ea6-8f3f-5cbbf0bf2449", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": false, - "versionViolation": false, - "violations": [ - { - "code": "SCHEMA_REMOVED", - "details": {}, - "message": "Schema removed from active contract", - "path": "schema[audit_log]", - "severity": "WARNING" - } - ] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "SCHEMA_REMOVED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "Breaking lifecycle changes require a major version bump", - "path": null, - "severity": "INFO" - }, - { - "code": "SCHEMA_REMOVED", - "details": {}, - "message": "Schema removed from active contract", - "path": "schema[audit_log]", - "severity": "WARNING" - } - ], - "requiredVersionBump": "major", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} From cf484d5589c32e67cc69af67efface9fe3e7453d Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:35:48 +1000 Subject: [PATCH 586/616] test(governance): remove obsolete dated decision snapshot --- .../validation_failure/expected.json | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 tests/fixtures/governance_scenarios/validation_failure/expected.json diff --git a/tests/fixtures/governance_scenarios/validation_failure/expected.json b/tests/fixtures/governance_scenarios/validation_failure/expected.json deleted file mode 100644 index 01eb8f5a..00000000 --- a/tests/fixtures/governance_scenarios/validation_failure/expected.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "breaking": false, - "changes": [], - "context": { - "effectiveDate": "2026-01-01" - }, - "contractId": "orders-contract", - "decision": "BLOCK", - "decisionId": "d887c18a-5d1f-57de-b374-7bb1fe5a4ae8", - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "policy": { - "idViolation": false, - "retiredViolation": false, - "valid": false, - "versionViolation": false, - "violations": [] - }, - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "VALIDATION_FAILED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "details": {}, - "message": "No contract changes detected", - "path": null, - "severity": "INFO" - }, - { - "code": "VALIDATION_FAILED", - "details": {}, - "message": "Property name cannot be empty or whitespace-only", - "path": "schema", - "severity": "ERROR" - } - ], - "requiredVersionBump": "none", - "schemaVersion": "1", - "validation": { - "issues": [ - { - "code": "VALIDATION_FAILED", - "details": {}, - "message": "Property name cannot be empty or whitespace-only", - "path": "schema", - "severity": "ERROR" - } - ], - "valid": false - } -} From 96be6a770c43697549e059b5ca27627b1c078a6f Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:36:26 +1000 Subject: [PATCH 587/616] test(lifecycle): decouple evaluation from mutation context --- tests/test_lifecycle_fail_closed_governance.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_lifecycle_fail_closed_governance.py b/tests/test_lifecycle_fail_closed_governance.py index 93283f39..2eaac1e0 100644 --- a/tests/test_lifecycle_fail_closed_governance.py +++ b/tests/test_lifecycle_fail_closed_governance.py @@ -61,7 +61,7 @@ def test_invalid_root_lifecycle_evaluates_to_block(): candidate = base.model_copy(deep=True) candidate.status = "invalid_status_xyz" - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert any( r.code == GovernanceReasonCode.VALIDATION_FAILED @@ -77,7 +77,7 @@ def test_invalid_schema_lifecycle_evaluates_to_block(): _cp("lifecycleStatus", "unknown_schema_status") ] - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert any( r.code == GovernanceReasonCode.VALIDATION_FAILED @@ -93,7 +93,7 @@ def test_invalid_property_lifecycle_evaluates_to_block(): _cp("lifecycleStatus", "invalid_prop_status") ] - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert any( r.code == GovernanceReasonCode.VALIDATION_FAILED @@ -109,7 +109,7 @@ def test_invalid_nested_property_lifecycle_evaluates_to_block(): _cp("lifecycleStatus", "garbage_nested_status") ] - decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + decision = evaluate_governance_decision(base, candidate) assert decision.decision == DecisionResult.BLOCK assert any( r.code == GovernanceReasonCode.VALIDATION_FAILED From 21bf486edaf73e037ebcc8b367248b494730643e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:36:30 +1000 Subject: [PATCH 588/616] test(cicd): remove stale release effective date --- tests/test_cicd_workflow_regression.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_cicd_workflow_regression.py b/tests/test_cicd_workflow_regression.py index 4c9a43de..7c208b17 100644 --- a/tests/test_cicd_workflow_regression.py +++ b/tests/test_cicd_workflow_regression.py @@ -429,8 +429,6 @@ def _adapter_factory(platform: str, **kwargs): f"git:base:{relative_path}", "--candidate-revision-ref", f"git:candidate:{relative_path}", - "--effective-date", - "2026-09-21", "--bundle-out", str(release_bundle), ) From 80b3a11a5e3502e07f2fce73c01bd26e52531dd8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:39:15 +1000 Subject: [PATCH 589/616] docs(deployment): remove effective date from assess workflows --- docs/deployment_plans.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 0eea9f55..38121dea 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -227,7 +227,6 @@ semapact deployment assess \ --candidate ./contracts/orders.candidate.yaml \ --base-revision-ref git:abc123 \ --candidate-revision-ref git:def456 \ - --effective-date 2026-09-20 \ --server development \ --bundle-out ./artifacts/orders-dev.deployment.bundle.json @@ -248,7 +247,6 @@ semapact release assess \ --candidate ./contracts/orders.candidate.yaml \ --base-revision-ref git:abc123 \ --candidate-revision-ref git:def456 \ - --effective-date 2026-09-20 \ --bundle-out ./artifacts/orders.release.bundle.json ``` From 66cfd65c8d1f852d431f3ce57bae40b3e2ea5606 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:39:19 +1000 Subject: [PATCH 590/616] docs(readme): remove execution date from release and deployment assess --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 92464ed3..add2fa9c 100644 --- a/README.md +++ b/README.md @@ -383,7 +383,6 @@ semapact deployment assess \ --candidate ./contracts/orders.candidate.yaml \ --base-revision-ref git:abc123 \ --candidate-revision-ref git:def456 \ - --effective-date 2026-09-20 \ --server development \ --bundle-out ./artifacts/orders-dev.bundle.json ``` @@ -396,7 +395,6 @@ semapact release assess \ --candidate ./contracts/orders.candidate.yaml \ --base-revision-ref git:abc123 \ --candidate-revision-ref git:def456 \ - --effective-date 2026-09-20 \ --bundle-out ./artifacts/orders.release.bundle.json ``` From 15ba8fe62fdf4b0ff388a73a303688e5e91fbe40 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 17:41:47 +1000 Subject: [PATCH 591/616] test(lifecycle): restore mutation context in fail-closed cases --- tests/test_fail_closed_governance_integration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_fail_closed_governance_integration.py b/tests/test_fail_closed_governance_integration.py index 6302fbc1..0fe29c69 100644 --- a/tests/test_fail_closed_governance_integration.py +++ b/tests/test_fail_closed_governance_integration.py @@ -104,7 +104,7 @@ def test_injected_block_decision_prevents_side_effects(tmp_path, monkeypatch): ) monkeypatch.setattr("semapact.core.lifecycle_cli._apply_contract", lambda c, s: setattr(c, "version", "2.0.0")) with pytest.raises(GovernanceBlockedError): - apply_lifecycle(lifecycle_args, is_promote=True) + apply_lifecycle(lifecycle_args, is_promote=True, context=TEST_CONTEXT) assert not (tmp_path / "lifecycle_out.yaml").exists() @@ -306,7 +306,7 @@ def test_breaking_change_review_behavior(tmp_path, capsys): property="amount", ) with pytest.raises(GovernanceReviewRequiredError) as exc: - apply_lifecycle(lifecycle_args, is_promote=False) + apply_lifecycle(lifecycle_args, is_promote=False, context=TEST_CONTEXT) assert exc.value.operation == GovernanceOperation.APPLY assert "Governance decision REVIEW required" in str(exc.value) From d845bd879b73e8404e942dab1f2c1fcf585472e2 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:37:41 +1000 Subject: [PATCH 592/616] feat(cli): accept approval artifact at release finalization --- semapact/interfaces/cli.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index a5750e17..522ced02 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -320,6 +320,13 @@ def _build_parser() -> argparse.ArgumentParser: help="Finalize one ReleaseBundle, record it, and materialize the versioned contract", ) release_finalize_parser.add_argument("--bundle", required=True) + release_finalize_parser.add_argument( + "--approval", + help=( + "Exact ApprovalRecord JSON artifact for a REVIEW release. " + "When omitted, SemaPact may resolve approval from the Git governance ledger." + ), + ) release_finalize_parser.add_argument( "--output-contract", required=True, From 2224ba05271598fbfa005bda3e71aa337bd656ec Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:37:44 +1000 Subject: [PATCH 593/616] feat(release): prefer exact approval artifact over history lookup --- semapact/interfaces/commands/release_cmd.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index 2fd1083e..28f3bab7 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -61,10 +61,15 @@ def run_release_finalize(args: argparse.Namespace) -> dict[str, Any]: from semapact.platforms.git import GitWorkingTreeHistoryRepository from semapact.utils.yaml_utils import dump_yaml + from semapact.approval import ApprovalRecord + bundle = _load_model(args.bundle, ReleaseBundle) repository = GitWorkingTreeHistoryRepository(args.repository_root) approval = None - if bundle.decision.decision is DecisionResult.REVIEW: + approval_path = getattr(args, "approval", None) + if approval_path: + approval = _load_model(approval_path, ApprovalRecord) + elif bundle.decision.decision is DecisionResult.REVIEW: approval = ReleaseApprovalResolver(repository).resolve(bundle) record = ReleaseFinalizer().finalize( From f111dad2b627e9b0d979ac4222882b4f78ed15d4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:37:54 +1000 Subject: [PATCH 594/616] test(cli): finalize release from exact approval artifact --- tests/interfaces/test_release_workflow_cmd.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/interfaces/test_release_workflow_cmd.py b/tests/interfaces/test_release_workflow_cmd.py index 4e1036e4..7825f19a 100644 --- a/tests/interfaces/test_release_workflow_cmd.py +++ b/tests/interfaces/test_release_workflow_cmd.py @@ -52,6 +52,7 @@ def test_release_finalize_materializes_selected_version_and_history(tmp_path) -> bundle = tmp_path / "release.bundle.json" released = tmp_path / "released.yaml" release_artifact = tmp_path / "contract-release.json" + approval_artifact = tmp_path / "approval.json" dump_yaml(_contract(), base) dump_yaml(_contract(include_created_at=True), candidate) @@ -73,12 +74,13 @@ def test_release_finalize_materializes_selected_version_and_history(tmp_path) -> recorded_at="2026-09-20T10:00:00+10:00", comment=None, repository_root=str(tmp_path), - approval_out=None, + approval_out=str(approval_artifact), ) ) result = run_release_finalize( SimpleNamespace( bundle=str(bundle), + approval=str(approval_artifact), output_contract=str(released), release_out=str(release_artifact), repository_root=str(tmp_path), @@ -94,5 +96,6 @@ def test_release_finalize_materializes_selected_version_and_history(tmp_path) -> assert record.source_revision_ref == "git:candidate" assert result["sourceRevisionRef"] == "git:candidate" assert record.released_contract_json + assert approval_artifact.is_file() assert release_artifact.is_file() assert release_artifact.read_text(encoding="utf-8").strip() From cca6fe32eb328f28240646afaae4f21afb603439 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:38:14 +1000 Subject: [PATCH 595/616] docs(examples): hand off release approval as exact artifact --- examples/github/data-product-ci-cd.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/github/data-product-ci-cd.yml b/examples/github/data-product-ci-cd.yml index 672f9f7a..4b53e963 100644 --- a/examples/github/data-product-ci-cd.yml +++ b/examples/github/data-product-ci-cd.yml @@ -146,21 +146,27 @@ jobs: name: semapact-release-bundle path: artifacts - - name: Persist exact review approval when required + - name: Capture exact review approval when required run: | BUNDLE=artifacts/release.bundle.json if [ "$(jq -r '.decision.decision' "${BUNDLE}")" = "REVIEW" ]; then semapact release approve \ --bundle "${BUNDLE}" \ --actor-reference "github-environment:contract-release/run:${GITHUB_RUN_ID}" \ - --recorded-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + --recorded-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --approval-out artifacts/release.approval.json fi - name: Finalize release and materialize released contract id: finalize run: | + APPROVAL_ARGS=() + if [ -f artifacts/release.approval.json ]; then + APPROVAL_ARGS=(--approval artifacts/release.approval.json) + fi semapact release finalize \ --bundle artifacts/release.bundle.json \ + "${APPROVAL_ARGS[@]}" \ --output-contract "${CONTRACT_PATH}" \ --release-out artifacts/contract-release.json \ > artifacts/finalize.json From 84cfecdaee452af512544208d805a3ce70d11125 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:38:17 +1000 Subject: [PATCH 596/616] docs(examples): hand off approvals without Git transport --- examples/github/central-contract-repo-ci-cd.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/github/central-contract-repo-ci-cd.yml b/examples/github/central-contract-repo-ci-cd.yml index cd1cf1d8..2696b50d 100644 --- a/examples/github/central-contract-repo-ci-cd.yml +++ b/examples/github/central-contract-repo-ci-cd.yml @@ -237,15 +237,20 @@ jobs: ARTIFACT=$(echo "${ITEM}" | jq -r '.artifact') BUNDLE="artifacts/releases/${ARTIFACT}.release.bundle.json" + APPROVAL_ARGS=() if [ "$(jq -r '.decision.decision' "${BUNDLE}")" = "REVIEW" ]; then + APPROVAL="artifacts/finalized/${ARTIFACT}.approval.json" semapact release approve \ --bundle "${BUNDLE}" \ --actor-reference "github-environment:contract-release/run:${GITHUB_RUN_ID}" \ - --recorded-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + --recorded-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --approval-out "${APPROVAL}" + APPROVAL_ARGS=(--approval "${APPROVAL}") fi semapact release finalize \ --bundle "${BUNDLE}" \ + "${APPROVAL_ARGS[@]}" \ --output-contract "${CONTRACT_ROOT}/${CONTRACT_PATH}" \ --release-out "artifacts/finalized/${ARTIFACT}.contract-release.json" \ > "/tmp/${ARTIFACT}.finalize.json" From 1c68a8647d0151d7c5a6b6d536ced3e68a6fc139 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:39:17 +1000 Subject: [PATCH 597/616] refactor(approval): separate history conflict check from artifact resolution --- .../application/services/release_approval.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/semapact/application/services/release_approval.py b/semapact/application/services/release_approval.py index 4e0a91a9..0e1c794e 100644 --- a/semapact/application/services/release_approval.py +++ b/semapact/application/services/release_approval.py @@ -16,6 +16,22 @@ def __init__(self, approvals: ApprovalHistoryRepository) -> None: self._approvals = approvals def resolve(self, bundle: ReleaseBundle) -> ApprovalRecord | None: + scoped = self._scoped_records(bundle) + if not scoped or self.has_conflict(bundle): + return None + return min(scoped, key=lambda record: record.approval_id) + + def has_conflict(self, bundle: ReleaseBundle) -> bool: + """Return whether exact persisted review evidence contains a non-approval.""" + return any( + record.action is not ReviewEvidenceAction.APPROVE + for record in self._scoped_records(bundle) + ) + + def _scoped_records( + self, + bundle: ReleaseBundle, + ) -> tuple[ApprovalRecord, ...]: records = self._approvals.list_approval_records_for_context( decision_id=bundle.decision.decision_id, change_set_id=bundle.change_set.change_set_id, @@ -23,14 +39,9 @@ def resolve(self, bundle: ReleaseBundle) -> ApprovalRecord | None: version_resolution_id=bundle.version_resolution.version_resolution_id, operation=GovernanceOperation.PUBLISH, ) - scoped = tuple( + return tuple( record for record in records if record.scope_reference == bundle.release_snapshot.release_snapshot_id and bundle.bundle_digest in record.evidence_references ) - if not scoped: - return None - if any(record.action is not ReviewEvidenceAction.APPROVE for record in scoped): - return None - return min(scoped, key=lambda record: record.approval_id) From 622f4f59dea67640a691f22676f6312b159c1478 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:39:21 +1000 Subject: [PATCH 598/616] fix(release): keep explicit approval handoff fail closed on ledger conflicts --- semapact/interfaces/commands/release_cmd.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index 28f3bab7..5a93c0c1 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -66,11 +66,18 @@ def run_release_finalize(args: argparse.Namespace) -> dict[str, Any]: bundle = _load_model(args.bundle, ReleaseBundle) repository = GitWorkingTreeHistoryRepository(args.repository_root) approval = None + resolver = ReleaseApprovalResolver(repository) approval_path = getattr(args, "approval", None) if approval_path: approval = _load_model(approval_path, ApprovalRecord) + if resolver.has_conflict(bundle): + from semapact.exceptions import ValidationError + + raise ValidationError( + "Persisted release approval history contains conflicting exact review evidence" + ) elif bundle.decision.decision is DecisionResult.REVIEW: - approval = ReleaseApprovalResolver(repository).resolve(bundle) + approval = resolver.resolve(bundle) record = ReleaseFinalizer().finalize( bundle, From 723510e1c1d4d4b04c20362942e2511cfaf653a4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:39:45 +1000 Subject: [PATCH 599/616] test(approval): fail closed on exact release review conflict --- tests/test_release_workflow_service.py | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_release_workflow_service.py b/tests/test_release_workflow_service.py index 21127183..40bb73d0 100644 --- a/tests/test_release_workflow_service.py +++ b/tests/test_release_workflow_service.py @@ -10,10 +10,12 @@ ) from pydantic import ValidationError as PydanticValidationError +from semapact.approval import build_approval_record from semapact.application.models.release import ReleaseBundle from semapact.application.services.release_approval import ReleaseApprovalResolver from semapact.application.services.release_workflow import ReleaseFinalizer, ReleaseWorkflowService from semapact.exceptions import ContractOpsAuthorizationError +from semapact.contractops import ReviewEvidenceAction from semapact.governance import DecisionResult, GovernanceOperation from semapact.platforms.git import GitWorkingTreeHistoryRepository @@ -137,3 +139,32 @@ def test_release_approval_resolver_finds_only_exact_bundle(tmp_path) -> None: update={"bundle_digest": "sha256:" + ("f" * 64)} ) assert ReleaseApprovalResolver(repository).resolve(tampered) is None + + + +def test_release_approval_resolver_fails_closed_on_exact_conflict(tmp_path) -> None: + bundle = _bundle() + repository = GitWorkingTreeHistoryRepository(tmp_path) + approval = ReleaseWorkflowService().approve( + bundle, + actor_reference="github:user:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + ) + rejection = build_approval_record( + decision_id=bundle.decision.decision_id, + change_set_id=bundle.change_set.change_set_id, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=bundle.version_resolution.version_resolution_id, + operation=GovernanceOperation.PUBLISH, + action=ReviewEvidenceAction.REJECT, + actor_reference="github:user:owner", + recorded_at=datetime(2026, 9, 20, 3, tzinfo=timezone.utc), + scope_reference=bundle.release_snapshot.release_snapshot_id, + evidence_references=(bundle.bundle_digest,), + ) + repository.put_approval_record(approval) + repository.put_approval_record(rejection) + + resolver = ReleaseApprovalResolver(repository) + assert resolver.has_conflict(bundle) is True + assert resolver.resolve(bundle) is None From c74f048cd82bd63226fd5f08721a406620f5ff24 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:40:00 +1000 Subject: [PATCH 600/616] docs(readme): show approval artifact handoff --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index add2fa9c..c9b850e0 100644 --- a/README.md +++ b/README.md @@ -404,10 +404,12 @@ For REVIEW releases, record the exact external approval and then finalize the re semapact release approve \ --bundle ./artifacts/orders.release.bundle.json \ --actor-reference github-environment:contract-release \ - --recorded-at 2026-09-20T10:00:00+10:00 + --recorded-at 2026-09-20T10:00:00+10:00 \ + --approval-out ./artifacts/orders.approval.json semapact release finalize \ --bundle ./artifacts/orders.release.bundle.json \ + --approval ./artifacts/orders.approval.json \ --output-contract ./contracts/orders.yaml \ --release-out ./artifacts/orders.contract-release.json ``` From 452752ee2ff06b5494d4da00d9865d5d01a471b8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:40:03 +1000 Subject: [PATCH 601/616] docs(deployment): make approval artifact the canonical handoff --- docs/deployment_plans.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 38121dea..d8320a7f 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -256,16 +256,18 @@ For a REVIEW release, an external workflow may persist exact approval with the c semapact release approve \ --bundle ./artifacts/orders.release.bundle.json \ --actor-reference github-environment:contract-release \ - --recorded-at 2026-09-20T10:00:00+10:00 + --recorded-at 2026-09-20T10:00:00+10:00 \ + --approval-out ./artifacts/orders.approval.json ``` -The approval is `PUBLISH`-scoped to the exact `ReleaseSnapshot` and `ReleaseBundle` digest. ALLOW releases do not require an ApprovalRecord. +The approval is `PUBLISH`-scoped to the exact `ReleaseSnapshot` and `ReleaseBundle` digest. The ApprovalRecord artifact is the CI/CD handoff; Git remains the durable governance ledger and is consulted only for conflict detection or fallback resolution. ALLOW releases do not require an ApprovalRecord. Finalize exactly once: ```bash semapact release finalize \ --bundle ./artifacts/orders.release.bundle.json \ + --approval ./artifacts/orders.approval.json \ --output-contract ./contracts/orders.yaml \ --release-out ./artifacts/orders.contract-release.json ``` From b017439a60b8acddd26a5b93767323633e764dc7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:40:06 +1000 Subject: [PATCH 602/616] docs(examples): document approval artifact handoff --- examples/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index e209d75e..c8111f92 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,7 +36,9 @@ Operational deployment history is config-first through `.semapact.yaml`. The exa do not repeat `--operational-history` on every deployment. The examples use two distinct GitHub Environment boundaries. `contract-release` supplies -human PUBLISH approval for REVIEW releases, recorded with `semapact release approve`. +human PUBLISH approval for REVIEW releases. `semapact release approve --approval-out` +persists that fact to the governance ledger and emits the exact ApprovalRecord used by +`release finalize --approval`; Git is not used as the in-pipeline transport. `production` protects whether the deployment job may run; SemaPact does not convert a finalized ContractRelease into DEPLOY authorization. From 302477c7a100fd21af14bf1267bce980cfb0321e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:41:05 +1000 Subject: [PATCH 603/616] refactor(approval): avoid duplicate ledger reads --- semapact/application/services/release_approval.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/semapact/application/services/release_approval.py b/semapact/application/services/release_approval.py index 0e1c794e..a72ff07e 100644 --- a/semapact/application/services/release_approval.py +++ b/semapact/application/services/release_approval.py @@ -17,16 +17,13 @@ def __init__(self, approvals: ApprovalHistoryRepository) -> None: def resolve(self, bundle: ReleaseBundle) -> ApprovalRecord | None: scoped = self._scoped_records(bundle) - if not scoped or self.has_conflict(bundle): + if not scoped or _contains_conflict(scoped): return None return min(scoped, key=lambda record: record.approval_id) def has_conflict(self, bundle: ReleaseBundle) -> bool: """Return whether exact persisted review evidence contains a non-approval.""" - return any( - record.action is not ReviewEvidenceAction.APPROVE - for record in self._scoped_records(bundle) - ) + return _contains_conflict(self._scoped_records(bundle)) def _scoped_records( self, @@ -45,3 +42,10 @@ def _scoped_records( if record.scope_reference == bundle.release_snapshot.release_snapshot_id and bundle.bundle_digest in record.evidence_references ) + + +def _contains_conflict(records: tuple[ApprovalRecord, ...]) -> bool: + return any( + record.action is not ReviewEvidenceAction.APPROVE + for record in records + ) From 3dcd3ec780b918dd6058f043bbf3c6cfc6422c35 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:41:36 +1000 Subject: [PATCH 604/616] refactor(deployment): align planner docs with canonical source model --- semapact/deployment/planner.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/semapact/deployment/planner.py b/semapact/deployment/planner.py index cee5484a..4e4b7367 100644 --- a/semapact/deployment/planner.py +++ b/semapact/deployment/planner.py @@ -21,7 +21,7 @@ def build_deployment_plan_from_source( source: DeploymentSourceSnapshot, target: DeploymentTarget, ) -> DeploymentPlan: - """Build a v5 plan from one exact deployment source snapshot.""" + """Build the canonical plan from one exact deployment source snapshot.""" if not isinstance(source, DeploymentSourceSnapshot): raise TypeError( "source must be DeploymentSourceSnapshot, " @@ -59,8 +59,8 @@ def build_deployment_actions( ) -> tuple[DeploymentAction, ...]: """Project candidate/released ODCS state into provider-neutral desired actions. - Actions are desired-state facts only. They are not executable authority until - they are bound into an exact DeploymentPlan derived from a release snapshot. + Actions are desired-state facts only. They are not an execution permission; + they become target-specific planning input only through an exact DeploymentPlan. """ if not isinstance(contract, OpenDataContractStandard): raise TypeError( From 486ce8ee354eae6061c094cab56bc2c4162878ef Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:52:03 +1000 Subject: [PATCH 605/616] test(cicd): exercise exact release approval artifact handoff --- tests/test_cicd_workflow_regression.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_cicd_workflow_regression.py b/tests/test_cicd_workflow_regression.py index 7c208b17..b040783d 100644 --- a/tests/test_cicd_workflow_regression.py +++ b/tests/test_cicd_workflow_regression.py @@ -205,6 +205,7 @@ def test_data_product_sample_command_chain_executes_end_to_end( candidate_bundle = tmp_path / "candidate.deployment.bundle.json" release_bundle = tmp_path / "release.bundle.json" released_contract = tmp_path / "released.yaml" + approval_artifact = tmp_path / "release.approval.json" release_artifact = tmp_path / "contract-release.json" production_bundle = tmp_path / "production.deployment.bundle.json" @@ -282,10 +283,13 @@ def _adapter_factory(platform: str, **kwargs): "github-environment:contract-release/run:1", "--recorded-at", "2026-09-21T04:00:00Z", + "--approval-out", + str(approval_artifact), "--repository-root", str(tmp_path), ) assert code == 0 + assert approval_artifact.is_file() # Finalization materializes ODCS and emits the immutable ContractRelease artifact. code, finalize_output = _run_cli( @@ -295,6 +299,8 @@ def _adapter_factory(platform: str, **kwargs): "finalize", "--bundle", str(release_bundle), + "--approval", + str(approval_artifact), "--output-contract", str(released_contract), "--release-out", @@ -413,6 +419,7 @@ def _adapter_factory(platform: str, **kwargs): base_path = base_root / relative_path candidate_path = candidate_root / relative_path release_bundle = tmp_path / "release-bundles" / f"{artifact_key}.json" + approval_artifact = tmp_path / "finalized" / f"{artifact_key}.approval.json" release_artifact = tmp_path / "finalized" / f"{artifact_key}.json" deployment_bundle = tmp_path / "deploy" / f"{artifact_key}.json" @@ -445,10 +452,13 @@ def _adapter_factory(platform: str, **kwargs): "github-environment:contract-release/run:central", "--recorded-at", "2026-09-21T04:00:00Z", + "--approval-out", + str(approval_artifact), "--repository-root", str(tmp_path), ) assert code == 0 + assert approval_artifact.is_file() code, finalize_output = _run_cli( monkeypatch, @@ -457,6 +467,8 @@ def _adapter_factory(platform: str, **kwargs): "finalize", "--bundle", str(release_bundle), + "--approval", + str(approval_artifact), "--output-contract", str(candidate_path), "--release-out", From 4ed10b4a33e26c0b7173e85d2e4bbc043ceb4135 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:52:17 +1000 Subject: [PATCH 606/616] docs(approval): align history with publish-only release approval --- docs/approval_history.md | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/approval_history.md b/docs/approval_history.md index e2204c43..db001917 100644 --- a/docs/approval_history.md +++ b/docs/approval_history.md @@ -102,25 +102,38 @@ record = service.record_review_action( Callers using another future persistence backend can provide the same `ApprovalHistoryRepository` capability without changing `ApprovalRecordService`. -## Deployment approval resolution +## Release approval handoff and resolution -For a **formal REVIEW release** (`DeploymentBundle.release=true`), callers do not have to pass an approval file explicitly. Candidate/non-release deployments never create or resolve release approval records. +Formal REVIEW approval belongs to the PUBLISH boundary, not to runtime deployment. -When `semapact deployment deploy --bundle ...` receives a formal REVIEW release bundle without `--approval`, the CLI queries Git-backed approval history under `.semapact/history/approval_records` and resolves only records that match all of the following: +The canonical CI/CD handoff is an exact `ApprovalRecord` artifact: + +```text +release assess +→ ReleaseBundle + +protected release environment +→ release approve --approval-out release.approval.json + +release finalize --approval release.approval.json +→ ContractRelease +``` + +The approval must match all of the following: - exact `decisionId`; - exact `changeSetId`; - exact `releasePlanId`; - exact `versionResolutionId`; -- operation `DEPLOY`; -- exact `deploymentPlanId` as scope; -- exact `bundleDigest` in evidence references. +- operation `PUBLISH`; +- exact `ReleaseSnapshot` as `scopeReference`; +- exact `ReleaseBundle` digest in `evidenceReferences`. -A matching `APPROVE` record can therefore be recorded by a trusted GitHub/Azure DevOps/GitLab integration before CD begins, while `deployment deploy` consumes it automatically. +`release approve` also persists the same approval in the Git governance ledger. That ledger is durable audit/conflict evidence, not the preferred in-pipeline transport. -If exact scoped history contains conflicting review actions such as `REQUEST_CHANGES` or `REJECT`, resolution fails closed. SemaPact does not apply a hidden "latest review wins" rule. +As a convenience fallback, `release finalize` may resolve the same exact PUBLISH approval from the Git ledger when `--approval` is omitted. If the exact scope contains conflicting review evidence such as `REQUEST_CHANGES` or `REJECT`, finalization fails closed. SemaPact does not apply a hidden "latest review wins" rule. -Passing `--approval ` remains supported for custom/manual formal-release workflows and takes precedence over Git-history lookup. Supplying an approval to a non-release deployment is rejected. +Runtime deployment has no SemaPact deployment-approval artifact. Whether `semapact deployment deploy` may run is controlled by the surrounding protected CI/CD execution context. Candidate deployment therefore does not create or resolve release approval records, and a finalized `ContractRelease` is provenance rather than DEPLOY authority. ## Trust boundary From cad262c2d7103a06266d4c8b2e03d6d404624ac4 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:52:34 +1000 Subject: [PATCH 607/616] docs(contractops): scope authorization to formal publication --- docs/contractops_authorization.md | 79 +++++++++++++------------------ 1 file changed, 34 insertions(+), 45 deletions(-) diff --git a/docs/contractops_authorization.md b/docs/contractops_authorization.md index e47f4b73..da5662b4 100644 --- a/docs/contractops_authorization.md +++ b/docs/contractops_authorization.md @@ -1,102 +1,91 @@ # ContractOps review authorization -SemaPact keeps governance classification and review authorization as separate immutable facts. +SemaPact keeps governance classification and formal-release approval as separate immutable facts. + +The current canonical use of ContractOps authorization is the PUBLISH boundary for a formal release: ```text GovernanceDecision(REVIEW) + -exact version-resolved release context +ChangeSet + + +ReleasePlan + + +VersionResolution + -matching explicit approval evidence +matching explicit PUBLISH approval evidence ↓ ContractOpsAuthorization(allowed=true) + ↓ +ContractRelease ``` The original `GovernanceDecision` remains `REVIEW`. Approval never rewrites it to `ALLOW`. ## Why authorization happens after version resolution -A review must authorize the release that will actually be executed, not an earlier approximation of it. +A reviewer must approve the release that will actually be published, including the selected semantic version. ```text GovernanceDecision → ChangeSet → ReleasePlan → VersionResolution -→ ContractOpsAuthorization -→ APPLY / PUBLISH / DEPLOY +→ ReleaseSnapshot +→ ReleaseBundle +→ PUBLISH approval when REVIEW +→ ContractRelease ``` -Review evidence is scoped to all of: +Review evidence is scoped to the exact release context: ```text decisionId changeSetId releasePlanId versionResolutionId -operation -scopeReference? # optional downstream scope +operation = PUBLISH +scopeReference = releaseSnapshotId +evidenceReference = releaseBundleDigest ``` -Changing the proposal, release plan, selected version, requested operation, or an explicitly bound downstream scope invalidates the evidence for that new action. +Changing the proposal, release plan, selected version, release snapshot, or bundle invalidates the approval for the new release. ## Decision behavior -| Governance gate result | Review evidence | Authorization | +| Governance gate result | Review evidence | Publication authorization | | --- | --- | --- | | `allowed` | not required | allowed by governance | -| `review_required` | missing | denied: authorization required | +| `review_required` | missing | denied: approval required | | `review_required` | exact `APPROVE` | allowed by review | | `review_required` | `REJECT` / `REQUEST_CHANGES` | denied | | `review_required` | stale or mismatched | denied | | `blocked` | any | denied; review cannot override BLOCK | -`GovernanceGateResult` remains authoritative for whether the operation is already allowed, requires review, or is blocked. ContractOps authorization only satisfies `review_required`; it does not re-run policy. +`GovernanceGateResult` remains authoritative for whether publication is already allowed, requires review, or is blocked. ContractOps authorization satisfies the review requirement; it does not re-run policy. ## Explicit evidence, not comment parsing -ContractOps consumes structured evidence: - -```text -ReviewAuthorizationEvidence -├── evidenceReference -├── decisionId -├── changeSetId -├── releasePlanId -├── versionResolutionId -├── operation -├── scopeReference? -└── action -``` - -`evidenceReference` is opaque. `scopeReference`, when present, is also opaque to ContractOps and may be used by a downstream boundary to bind approval to an exact target-specific artifact such as a deployment plan. - -This layer does not store approvals or infer approval from human comments, PR text, Slack messages, or similar free-form content. Durable review history and reviewer workflow are separate persistence/application concerns and can project structured evidence into this boundary without changing its authorization semantics. +ContractOps consumes structured `ReviewAuthorizationEvidence`. The evidence reference and scope reference are opaque identifiers whose exact values are produced by the release workflow. -## Operation scope +This layer does not parse human comments, PR text, Slack messages, or similar free-form content. `ApprovalRecord` is the durable structured fact; Git history is an audit/conflict ledger and the exact approval artifact is the preferred CI/CD handoff. -Approval is not a generic bypass token. +## Runtime deployment is a separate trust boundary -```text -approval for APPLY -≠ approval for PUBLISH -≠ approval for DEPLOY -``` +A `ContractRelease` proves what was formally released. It does not grant permission to mutate a runtime. -Likewise, approval for one `VersionResolution` does not authorize a different selected version. +SemaPact does not create a `DeploymentAuthorization` artifact. Runtime execution permission belongs to the surrounding protected CI/CD environment, such as a GitHub or Azure DevOps Environment. The deployment domain instead validates exact source/plan provenance, re-observes runtime state, derives a fresh preview, applies only supported transitions, and verifies convergence. -Runtime deployment adds another scope boundary: a review-required deployment must bind approval to the exact `DeploymentPlan`, so approval for one runtime target cannot be rebound to another target. +Candidate deployment likewise does not create release approval or release history. `BLOCK` fails closed; `REVIEW` may still be used for non-release validation/test deployment because no formal publication occurs. -## Invalid context vs denied authorization +## Invalid context vs denied publication -SemaPact distinguishes malformed artifact composition from a valid release that simply lacks approval. +SemaPact distinguishes malformed release composition from a valid release that lacks required approval. If `GovernanceDecision`, `ChangeSet`, `ReleasePlan`, and `VersionResolution` do not refer to the same immutable release context, authorization fails closed with a validation error. -If the release context is valid but evidence is missing, rejected, stale, or mismatched, SemaPact returns a deterministic `ContractOpsAuthorization` with `allowed=false` and a machine-readable reason. - -This lets APPLY, PUBLISH, and DEPLOY boundaries consume one authoritative release-context authorization result without implementing their own approval rules. +If the release context is valid but required PUBLISH evidence is missing, rejected, stale, or mismatched, SemaPact returns a deterministic `ContractOpsAuthorization` with `allowed=false` and a machine-readable reason. ## Non-goals -This boundary does not implement approval persistence, reviewer routing, quorum, IAM/SSO, approval UI, GitHub review parsing, or BLOCK overrides. +This boundary does not implement approval persistence mechanics, reviewer routing, quorum, IAM/SSO, approval UI, GitHub review parsing, runtime deployment permission, or BLOCK overrides. From fdfb5be394fc447d6688cf5a282c04b71d4fd7f0 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:53:13 +1000 Subject: [PATCH 608/616] docs(architecture): make release and deployment boundaries canonical --- ARCHITECTURE.md | 198 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 138 insertions(+), 60 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 252cd38a..6155d253 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,9 +2,9 @@ ## Purpose -SemaPact is an ODCS-first, change-driven contract governance and production-assurance control plane. It separates governed contract semantics, release authorization, runtime mutation, and runtime verification so each concern has one authoritative owner. +SemaPact is an ODCS-first, change-driven contract governance and production-assurance control plane. It separates governed contract semantics, formal release publication, runtime deployment, and runtime verification so each concern has one authoritative owner. -The canonical product flow is: +The canonical product flow has two paths after governance: ```text ODCS base + candidate @@ -12,23 +12,49 @@ ODCS base + candidate lifecycle / governance ↓ GovernanceDecision - ↓ -ChangeSet → ReleasePlan → VersionResolution - ↓ -ContractOpsAuthorization - ↓ -AppliedContractRelease - ↓ -DeploymentPlan → DeploymentAuthorization - ↓ -DeploymentAdapter - ↓ -runtime - ↓ -observation / reconciliation + ├──────────────────────────────────────────────┐ + │ candidate deployment │ formal release + │ │ + ↓ ↓ +DeploymentSourceSnapshot(candidate) ChangeSet + ↓ ↓ +DeploymentPlan ReleasePlan + ↓ ↓ +CI-time DeploymentPreview VersionResolution + ↓ ↓ +DeploymentBundle ReleaseSnapshot + │ ↓ + │ ReleaseBundle + │ ↓ + │ PUBLISH approval + │ when REVIEW + │ ↓ + │ ContractRelease + │ ↓ + │ DeploymentSourceSnapshot(contract_release) + │ ↓ + │ DeploymentPlan + │ ↓ + │ CI-time DeploymentPreview + │ ↓ + └───────────────────────┬──────────────DeploymentBundle + ↓ + protected CD execution context + ↓ + fresh runtime observation + ↓ + fresh DeploymentPreview + ↓ + apply + ↓ + observation / reconciliation + ↓ + IN_SYNC / DRIFT / INDETERMINATE ``` -Later phases consume exact artifacts from earlier phases. They do not re-run governance, lifecycle classification, version authority, or approval semantics. +Later phases consume exact immutable artifacts from earlier phases. They do not re-run governance, version selection, approval semantics, or release identity. + +Formal release approval protects PUBLISH. Runtime deployment permission belongs to the surrounding protected CI/CD execution context. A `ContractRelease` is release provenance, not DEPLOY authorization. ## Dependency Direction @@ -55,13 +81,15 @@ Domain models live with the rules that give them meaning: - `semapact/lifecycle/` — canonical identity, lifecycle policy, merge/change semantics; - `semapact/governance/` — `GovernanceDecision`, reason codes, centralized gate; - `semapact/revision/` — immutable governed contract content identity and source-provenance links; -- `semapact/contractops/` — `ChangeSet`, `ReleasePlan`, `VersionResolution`, ContractOps authorization, APPLY/PUBLISH artifacts; -- `semapact/deployment/` — provider-neutral `DeploymentPlan`, `DeploymentPreview`, deployment authorization and adapter contract; +- `semapact/approval/` — immutable structured review evidence; +- `semapact/contractops/` — `ChangeSet`, `ReleasePlan`, `VersionResolution`, `ReleaseSnapshot`, publication authorization, and `ContractRelease`; +- `semapact/deployment/` — `DeploymentSourceSnapshot`, provider-neutral `DeploymentPlan`, `DeploymentPreview`, provenance validation, and adapter contracts; - `semapact/runtime/` — provider-neutral governed runtime asset projection; - `semapact/observation/` — provider-neutral point-in-time runtime state; -- `semapact/reconciliation/` — desired-vs-observed comparison and `RuntimeDriftStatus`. +- `semapact/reconciliation/` — desired-vs-observed comparison and `RuntimeDriftStatus`; +- `semapact/history/` — storage-neutral typed governance and operational-history ports/models. -A domain artifact does not move into the application or persistence layer merely because an application service returns it or a history backend stores it. +A domain artifact does not move into the application or persistence layer merely because an application service returns it or a backend stores it. ### Application layer @@ -73,39 +101,40 @@ semapact/application/ └── services/ ``` -`application/services/` owns thin, interface-independent use-case orchestration. It may resolve workflow context/configuration and compose existing domain functions or ports, but it must not reimplement domain policy. +`application/services/` owns thin, interface-independent use-case orchestration. It may resolve configuration and compose existing domain functions or ports, but it must not reimplement domain policy. `application/models/` owns typed use-case results that aggregate canonical domain artifacts. Examples include: - `GovernanceAnalysis`; - `GovernanceProposal`; - `RuntimeReconciliation`; -- `ReleasePlanningResult`. - -These are application DTOs, not new governance/release/deployment authorities. - -### Compatibility package +- `ReleasePlanningResult`; +- deployment workflow result DTOs. -`semapact/services/` is a backward-compatible import surface for the former package layout. It contains re-exports only and owns no models or business logic. New code must import from `semapact.application`. +These are application DTOs, not new governance, release, deployment, or reconciliation authorities. ### Interfaces -`semapact/interfaces/` owns parsing, loading input artifacts at the interface edge, rendering, and process-outcome mapping. Interfaces delegate to application/domain boundaries and must not independently calculate governance, version, deployment, or reconciliation results. +`semapact/interfaces/` owns argument parsing, loading input artifacts at the interface edge, rendering, and process-outcome mapping. Interfaces delegate to application/domain boundaries and must not independently calculate governance, versions, deployment plans, or reconciliation results. ### History persistence -`semapact/history/` owns storage-neutral typed persistence/query ports and persistence errors. It stores canonical artifacts from their owning domains but does not redefine their models, identity formulas, or business semantics. +Git is the low-frequency governance ledger for facts such as `ApprovalRecord` and `ContractRelease`. It is not the CI/CD artifact transport and not an operational telemetry database. + +Operational deployment telemetry is disabled by default and can be configured through typed `history.operational` settings with SQLite or Delta backends. High-frequency deployment/reconciliation events are never written to Git by the canonical workflow. ### Platform adapters -`semapact/platforms/` owns provider SDK/client integration and physical-platform translation. Databricks DDL generation/execution is provider behavior; it does not belong in ContractOps or application DTOs. Storage backend layout and physical persistence mechanics likewise belong to the corresponding platform adapter. +`semapact/platforms/` owns provider SDK/client integration and physical-platform translation. Databricks DDL generation/execution and Unity Catalog tag projection are provider behavior; they do not belong in ContractOps or application DTOs. -### Import/export and compatibility workflows +### Import/export and integration workflows - `semapact/importers/` projects explicitly imported external metadata into ODCS and contains no lifecycle policy; - runtime observation evidence is not an importer source of canonical ODCS semantics; evidence-assisted bootstrap or enrichment must produce an explicit proposal/candidate for normal governance rather than mutate a governed contract; - `semapact/exporters/` and `quality/` are read-only projections; -- `semapact/devops/` and parts of `core/` contain stable compatibility workflows and must not become a second canonical ContractOps implementation. +- `semapact/devops/` contains Git/CI integration helpers only and must not become a second canonical release implementation. + +SemaPact is treated as a greenfield architecture. Superseded package aliases, artifact schemas, and compatibility workflows are removed rather than retained as parallel public surfaces. ## Model Placement Rule @@ -115,10 +144,12 @@ Do not create a generic root `schema/`, `models/`, or `data_models/` directory t | --- | --- | | governed ODCS contract | ODCS model | | governed contract revision identity/provenance | `semapact/revision/` | -| governance/release/deployment/reconciliation artifact | owning domain package | +| structured review evidence | `semapact/approval/` | +| release artifact | `semapact/contractops/` or release application DTO owner | +| deployment source/plan/preview artifact | `semapact/deployment/` | | application/use-case aggregate result | `application/models/` | | application orchestration | `application/services/` | -| storage-neutral history persistence/query capability | `semapact/history/` | +| storage-neutral persistence/query capability | `semapact/history/` | | provider/SDK/physical representation | `platforms//` | | presentation-only rendering state | `interfaces/` | @@ -128,9 +159,7 @@ The fact that every object is “data” is not a useful architectural boundary. SemaPact reuses `OpenDataContractStandard` as the canonical logical contract model. It must not create a second contract representation merely to support governance, revision history, persistence, or deployment. -Domain artifacts may reference or wrap the canonical ODCS model while adding only semantics owned by that domain. For example, `ContractRevision` adds content identity around an exact `OpenDataContractStandard`; it does not duplicate `contract.id`, `contract.version`, schema fields, or a serialized contract copy as parallel logical fields. - -Canonical JSON may be derived transiently for deterministic hashing, signatures, persistence, or transport. That serialization is not a second logical contract model. +Domain artifacts may reference or serialize the canonical ODCS model while adding only semantics owned by that domain. Canonical JSON may be derived for deterministic hashing, persistence, or transport; that serialization is not a second logical contract model. ## Governed Identity @@ -145,34 +174,60 @@ property identity = lowercase(schema.name) + lowercase(property.name) ## Lifecycle and Governance -Lifecycle/governance is the sole authority for change meaning. Active entities participate in governance; draft/deprecated entities are excluded where policy specifies; retired state is immutable. Interfaces, application services, adapters, and exporters must not independently reinterpret these rules. +Lifecycle/governance is the sole authority for change meaning. Interfaces, application services, adapters, and exporters must not independently reinterpret those rules. + +A business effective date is mutation context only, for operations that materialize dated lifecycle state such as deprecation. Pure governance evaluation, release assessment, repository classification, and deployment assessment are date-independent. Governance produces one immutable `GovernanceDecision`. Downstream phases consume that decision and its projected artifacts rather than diffing again. -## ContractOps Release Boundary +## Formal Release Boundary -Canonical planning is: +Canonical formal release planning is: ```text base + candidate + exact revision refs ↓ -GovernanceDecision + ChangeSet +GovernanceDecision + ↓ +ChangeSet ↓ ReleasePlan ↓ VersionResolution + ↓ +ReleaseSnapshot + ↓ +ReleaseBundle ``` Version selection is separate from governance classification. SemaPact-managed and Git-managed authority both resolve through the canonical version-authority boundary. -APPLY materializes the exact released ODCS snapshot only after matching authorization. PUBLISH publishes a released artifact and is distinct from DEPLOY. +For `REVIEW`, an exact `ApprovalRecord` must bind the PUBLISH operation, exact `ReleaseSnapshot`, and exact `ReleaseBundle` digest. The preferred CI/CD handoff is the approval artifact itself; Git retains the same fact for audit/conflict detection and fallback lookup. + +Finalization creates one target-neutral `ContractRelease` and materializes the selected semantic version into the released ODCS snapshot. The same `ContractRelease` may subsequently be deployed to multiple targets without another version calculation. + +Candidate deployment bypasses this formal release path entirely. It does not calculate a release version, create release approval, or write release history. ## Deployment Boundary -Deployment planning starts from an exact `AppliedContractRelease`: +Deployment planning starts from one exact `DeploymentSourceSnapshot`: ```text -AppliedContractRelease + DeploymentTarget +candidate contract + governance decision + ↓ +DeploymentSourceSnapshot(source_kind=candidate) + +or + +ContractRelease + ↓ +DeploymentSourceSnapshot(source_kind=contract_release) +``` + +The source snapshot plus one exact target produces the deployment artifacts: + +```text +DeploymentSourceSnapshot + DeploymentTarget ↓ DeploymentPlan ↓ @@ -180,12 +235,34 @@ fresh runtime observation + adapter preview ↓ DeploymentPreview ↓ -exact plan + preview + DeploymentAuthorization +DeploymentBundle +``` + +The CI-time preview is review evidence only; CD never blindly replays it. At execution time: + +```text +protected CI/CD execution context + + +exact DeploymentBundle + ↓ +validate source / plan / bundle integrity + ↓ +fresh runtime observation + ↓ +fresh DeploymentPreview ↓ -DeploymentAdapter.execute(...) +apply supported operations + ↓ +fresh verification + ↓ +IN_SYNC / DRIFT / INDETERMINATE ``` -`DeploymentPlan` stays provider-neutral. Provider-native CREATE/ALTER/NO_OP operations begin at the adapter boundary. Runtime mutation must fail closed when capability or evidence is insufficient. +SemaPact does not create a `DeploymentAuthorization` artifact. Runtime execution permission is delegated to the surrounding protected environment. The deployment domain owns provenance validation and runtime freshness, not IAM approval policy. + +Candidate `BLOCK` decisions fail closed. Candidate `REVIEW` may still be deployed for validation/test because no formal publication occurs. + +For Databricks, a finalized formal release that reaches `IN_SYNC` also projects reserved SemaPact release provenance tags to governed Unity Catalog tables. Candidate deployments do not publish formal release/version tags. Provider execution success is not convergence proof. @@ -193,9 +270,9 @@ Provider execution success is not convergence proof. Observation captures platform-neutral runtime evidence. It never mutates ODCS or invokes governance. -Lineage is time-varying runtime evidence, not canonical contract truth. Table/column lineage and query history may support provenance, impact analysis, reconciliation, or an explicit contract proposal, but observation must not directly project them into authoritative ODCS fields such as `transformSourceObjects` or `transformLogic`. +Lineage is time-varying runtime evidence, not canonical contract truth. Table/column lineage and query history may support provenance, impact analysis, reconciliation, or an explicit contract proposal, but observation must not directly project them into authoritative ODCS fields. -Reconciliation compares governed desired state with fresh observation and yields the existing status vocabulary: +Reconciliation compares governed desired state with fresh observation and yields: ```text IN_SYNC @@ -207,7 +284,7 @@ Deployment verification reuses this same reconciliation authority rather than in ## Application Services -Application services exist only when a use case genuinely coordinates multiple domain/port calls. Current examples include governance context construction, configured version authority, canonical release planning, runtime reconciliation orchestration, and deployment orchestration. +Application services exist only when a use case genuinely coordinates multiple domain/port calls. Rules: @@ -220,13 +297,14 @@ Rules: ## Public Architecture Invariants -1. **Change-driven, not CRUD** — governed state evolves through explicit analysis/planning/authorization boundaries. -2. **One authority per rule** — lifecycle, governance, revision identity, version selection, deployment translation, and reconciliation each have one canonical owner. +1. **Change-driven, not CRUD** — governed state evolves through explicit analysis, release, deployment, and reconciliation boundaries. +2. **One authority per rule** — lifecycle, governance, version selection, release publication, deployment translation, and reconciliation each have one canonical owner. 3. **Exact artifacts cross boundaries** — side effects consume exact immutable artifacts; mutable current state is not silently substituted. -4. **Operation-scoped authorization** — APPLY, PUBLISH, and DEPLOY are distinct operations; authorization for one cannot authorize another. -5. **Logical identity is stable** — `physicalName` binds runtime state but does not redefine governed identity. -6. **Execution is not convergence** — runtime state must be observed and reconciled independently. -7. **Interfaces stay thin** — CLI/API/UI parse, delegate, and render; they do not become a second business-logic implementation. -8. **Compatibility is not ownership** — legacy import paths may re-export canonical implementations but must not accumulate new logic. -9. **One canonical contract model** — SemaPact reuses ODCS rather than maintaining a parallel contract schema. -10. **Evidence is not contract truth** — runtime evidence may inform analysis or an explicit governed proposal, but it never silently mutates canonical ODCS semantics. +4. **PUBLISH approval is not DEPLOY authority** — release approval protects formal publication; runtime mutation permission comes from the protected execution environment. +5. **Candidate deployment is not release** — it performs no release version calculation, approval persistence, or release-history write. +6. **Logical identity is stable** — `physicalName` binds runtime state but does not redefine governed identity. +7. **Execution is not convergence** — runtime state must be observed and reconciled independently. +8. **Interfaces stay thin** — CLI/API/UI parse, delegate, and render; they do not become a second business-logic implementation. +9. **Greenfield means one surface** — superseded aliases, legacy artifacts, and compatibility workflows are removed rather than maintained beside canonical paths. +10. **One canonical contract model** — SemaPact reuses ODCS rather than maintaining a parallel contract schema. +11. **Evidence is not contract truth** — runtime evidence may inform analysis or an explicit governed proposal, but it never silently mutates canonical ODCS semantics. From b9e8c49563776f091c850e5dd853ad23e747cde7 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:53:41 +1000 Subject: [PATCH 609/616] refactor(deployment): name provenance validation by responsibility --- semapact/deployment/provenance.py | 75 +++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 semapact/deployment/provenance.py diff --git a/semapact/deployment/provenance.py b/semapact/deployment/provenance.py new file mode 100644 index 00000000..e425b913 --- /dev/null +++ b/semapact/deployment/provenance.py @@ -0,0 +1,75 @@ +"""Canonical deployment provenance validation.""" + +from __future__ import annotations + +from semapact.deployment.models import ( + DeploymentPlan, + validate_deployment_plan_identity, +) +from semapact.deployment.source import DeploymentSourceSnapshot +from semapact.contractops import ContractRelease +from semapact.governance import DecisionResult, GovernanceDecision +from semapact.exceptions import ReleaseValidationError + + +def validate_candidate_deployment_context( + plan: DeploymentPlan, + source: DeploymentSourceSnapshot, + decision: GovernanceDecision, +) -> None: + """Validate candidate deployment provenance and fail closed on BLOCK.""" + if source.source_kind != "candidate": + raise ReleaseValidationError( + "Candidate deployment requires candidate source without release provenance" + ) + validate_deployment_plan_identity(plan) + if plan.source_snapshot_id != source.source_snapshot_id: + raise ReleaseValidationError( + "DeploymentPlan does not reference the supplied candidate source" + ) + if plan.contract_id != source.contract_id: + raise ReleaseValidationError( + "DeploymentPlan and candidate source contract IDs do not match" + ) + if decision.contract_id != source.contract_id: + raise ReleaseValidationError( + "GovernanceDecision does not match candidate deployment contract" + ) + if decision.decision is DecisionResult.BLOCK: + raise ReleaseValidationError("Candidate deployment is blocked by governance") + + +def validate_contract_release_deployment_context( + plan: DeploymentPlan, + source: DeploymentSourceSnapshot, + release: ContractRelease, +) -> None: + """Validate that one plan is derived from the exact finalized release.""" + if source.source_kind != "contract_release": + raise ReleaseValidationError( + "Finalized release deployment requires ContractRelease source provenance" + ) + validate_deployment_plan_identity(plan) + if plan.source_snapshot_id != source.source_snapshot_id: + raise ReleaseValidationError( + "DeploymentPlan does not reference the supplied release source" + ) + if source.release_id != release.contract_release_id: + raise ReleaseValidationError( + "Deployment source does not reference the finalized ContractRelease" + ) + if source.contract_id != release.contract_id: + raise ReleaseValidationError( + "Deployment source and ContractRelease contract IDs do not match" + ) + if source.contract_version != release.contract_version: + raise ReleaseValidationError( + "Deployment source and ContractRelease versions do not match" + ) + if source.revision_ref != release.source_revision_ref: + raise ReleaseValidationError( + "Deployment source and ContractRelease revisions do not match" + ) + + + From e9fb05af06a8926ee9caff72c1f1648c12c304a8 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:53:45 +1000 Subject: [PATCH 610/616] refactor(deployment): import canonical provenance validator --- semapact/deployment/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index f1220884..e80bc177 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -5,7 +5,7 @@ RuntimeReleaseMetadata, RuntimeReleaseMetadataProjector, ) -from semapact.deployment.authorization import ( +from semapact.deployment.provenance import ( validate_candidate_deployment_context, validate_contract_release_deployment_context, ) From c9e32499a4efb333235778019f6e73eaa102f47e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:53:48 +1000 Subject: [PATCH 611/616] refactor(deployment): remove obsolete authorization module name --- semapact/deployment/authorization.py | 75 ---------------------------- 1 file changed, 75 deletions(-) delete mode 100644 semapact/deployment/authorization.py diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py deleted file mode 100644 index e425b913..00000000 --- a/semapact/deployment/authorization.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Canonical deployment provenance validation.""" - -from __future__ import annotations - -from semapact.deployment.models import ( - DeploymentPlan, - validate_deployment_plan_identity, -) -from semapact.deployment.source import DeploymentSourceSnapshot -from semapact.contractops import ContractRelease -from semapact.governance import DecisionResult, GovernanceDecision -from semapact.exceptions import ReleaseValidationError - - -def validate_candidate_deployment_context( - plan: DeploymentPlan, - source: DeploymentSourceSnapshot, - decision: GovernanceDecision, -) -> None: - """Validate candidate deployment provenance and fail closed on BLOCK.""" - if source.source_kind != "candidate": - raise ReleaseValidationError( - "Candidate deployment requires candidate source without release provenance" - ) - validate_deployment_plan_identity(plan) - if plan.source_snapshot_id != source.source_snapshot_id: - raise ReleaseValidationError( - "DeploymentPlan does not reference the supplied candidate source" - ) - if plan.contract_id != source.contract_id: - raise ReleaseValidationError( - "DeploymentPlan and candidate source contract IDs do not match" - ) - if decision.contract_id != source.contract_id: - raise ReleaseValidationError( - "GovernanceDecision does not match candidate deployment contract" - ) - if decision.decision is DecisionResult.BLOCK: - raise ReleaseValidationError("Candidate deployment is blocked by governance") - - -def validate_contract_release_deployment_context( - plan: DeploymentPlan, - source: DeploymentSourceSnapshot, - release: ContractRelease, -) -> None: - """Validate that one plan is derived from the exact finalized release.""" - if source.source_kind != "contract_release": - raise ReleaseValidationError( - "Finalized release deployment requires ContractRelease source provenance" - ) - validate_deployment_plan_identity(plan) - if plan.source_snapshot_id != source.source_snapshot_id: - raise ReleaseValidationError( - "DeploymentPlan does not reference the supplied release source" - ) - if source.release_id != release.contract_release_id: - raise ReleaseValidationError( - "Deployment source does not reference the finalized ContractRelease" - ) - if source.contract_id != release.contract_id: - raise ReleaseValidationError( - "Deployment source and ContractRelease contract IDs do not match" - ) - if source.contract_version != release.contract_version: - raise ReleaseValidationError( - "Deployment source and ContractRelease versions do not match" - ) - if source.revision_ref != release.source_revision_ref: - raise ReleaseValidationError( - "Deployment source and ContractRelease revisions do not match" - ) - - - From 839566e1d9930e4a0ef67549fbb30062538fb14a Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:54:11 +1000 Subject: [PATCH 612/616] refactor(deployment): remove obsolete authorization terminology --- semapact/deployment/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index 9689cafd..2ffb8d13 100644 --- a/semapact/deployment/models.py +++ b/semapact/deployment/models.py @@ -1,4 +1,4 @@ -"""Provider-neutral immutable deployment planning, preview, and authorization artifacts.""" +"""Provider-neutral immutable deployment planning and preview artifacts.""" from __future__ import annotations From 06261e25e735d43999fc525cd8004ad05a98284e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:54:15 +1000 Subject: [PATCH 613/616] refactor(contractops): remove deployment approval example --- semapact/contractops/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semapact/contractops/models.py b/semapact/contractops/models.py index 18b1bb56..7e164529 100644 --- a/semapact/contractops/models.py +++ b/semapact/contractops/models.py @@ -188,8 +188,8 @@ class ReviewAuthorizationEvidence(ContractOpsModel): """Opaque review evidence projected onto one exact version-resolved action. ``scope_reference`` is optional downstream scope provenance. ContractOps preserves - but does not interpret it. For example, deployment review can bind the evidence - to an exact ``DeploymentPlan`` without making ContractOps depend on deployment. + but does not interpret it. The formal release workflow uses it to bind PUBLISH + approval to the exact ``ReleaseSnapshot``. """ evidence_reference: str From b7242f7cc87f471f2cf1a4b4941e06a14979c2e1 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 19:54:20 +1000 Subject: [PATCH 614/616] docs(deployment): remove obsolete authorization wording --- docs/deployment_plans.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index d8320a7f..36c8ff3f 100644 --- a/docs/deployment_plans.md +++ b/docs/deployment_plans.md @@ -138,7 +138,7 @@ DeploymentTarget `platform` is the downstream adapter dispatch key. `runtimeTarget` is an opaque provider-local product target. `sourceReference` identifies the exact runtime source/end point against which the plan is authorized; for Databricks this is the workspace host used by runtime observation. It is an identity reference, never a credential. -Preview, execution, and verification fail closed when fresh runtime evidence comes from a different source than the plan's `sourceReference`. This prevents an authorization for the same catalog/schema name from being reused against another workspace. +Preview, execution, and verification fail closed when fresh runtime evidence comes from a different source than the plan's `sourceReference`. This prevents a plan or bundle prepared for one catalog/schema binding from being reused against another workspace. The plan does not contain credentials, workspace clients, SQL connections, or provider sessions. From 218d921906fdf0c830820740e61ffdb4ec4074f0 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 21:13:28 +1000 Subject: [PATCH 615/616] fix(deployment): bind candidate bundle to exact change set --- semapact/application/models/deployment_workflow.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py index bad50a28..917a1acd 100644 --- a/semapact/application/models/deployment_workflow.py +++ b/semapact/application/models/deployment_workflow.py @@ -106,6 +106,14 @@ def _validate_bundle_links(self) -> "DeploymentBundle": raise ValueError( "DeploymentBundle source/decision contract mismatch" ) + if self.change_set.candidate_revision_ref != source.revision_ref: + raise ValueError( + "DeploymentBundle candidate revision does not match ChangeSet" + ) + if self.change_set.changes != self.decision.changes: + raise ValueError( + "DeploymentBundle decision/change-set changes mismatch" + ) if plan.source_snapshot_id != source.source_snapshot_id: raise ValueError( From 04ab2cc8129fdb9d679c982c7a287f1dc499c4fa Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 21:13:38 +1000 Subject: [PATCH 616/616] test(deployment): reject mixed candidate governance artifacts --- tests/test_deployment_workflow_service.py | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py index 41d29176..ccc1ef02 100644 --- a/tests/test_deployment_workflow_service.py +++ b/tests/test_deployment_workflow_service.py @@ -296,6 +296,60 @@ def test_bundle_rehydration_fails_closed_when_digest_is_tampered() -> None: type(bundle).model_validate(payload) +def test_candidate_bundle_rejects_change_set_from_other_revision() -> None: + service = DeploymentWorkflowService() + first = service.assess( + _contract(name="Orders"), + _contract(name="Orders"), + base_revision_ref="git:base", + candidate_revision_ref="git:candidate-a", + target=_target(), + adapter=_PreviewAdapter(), + ) + second = service.assess( + _contract(name="Orders"), + _contract(name="Orders"), + base_revision_ref="git:base", + candidate_revision_ref="git:candidate-b", + target=_target(), + adapter=_PreviewAdapter(), + ) + assert second.change_set is not None + + payload = first.model_dump(mode="json") + payload["change_set"] = second.change_set.model_dump(mode="json") + + with pytest.raises(PydanticValidationError, match="candidate revision"): + type(first).model_validate(payload) + + +def test_candidate_bundle_rejects_decision_from_different_change_set() -> None: + service = DeploymentWorkflowService() + first = service.assess( + _contract(name="Orders"), + _contract(name="Orders"), + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=_target(), + adapter=_PreviewAdapter(), + ) + second = service.assess( + _contract(name="Orders"), + _contract(name="Orders", include_created_at=True), + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=_target(), + adapter=_PreviewAdapter(), + ) + assert second.decision is not None + + payload = first.model_dump(mode="json") + payload["decision"] = second.decision.model_dump(mode="json") + + with pytest.raises(PydanticValidationError, match="decision/change-set changes mismatch"): + type(first).model_validate(payload) + + def test_candidate_review_deployment_needs_no_release_approval() -> None: service = DeploymentWorkflowService() bundle = service.assess(