diff --git a/.semapact.yaml b/.semapact.yaml index b1f82569..f1e9f0b6 100644 --- a/.semapact.yaml +++ b/.semapact.yaml @@ -17,3 +17,4 @@ llm: model_name: gpt-4-turbo api_key: '' base_url: '' + 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. diff --git a/README.md b/README.md index aff85e2c..c9b850e0 100644 --- a/README.md +++ b/README.md @@ -67,20 +67,32 @@ 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 -→ ContractOpsAuthorization -→ AppliedContractRelease -→ DeploymentPlan -→ DeploymentAuthorization -→ runtime mutation + ├─ candidate deployment + │ → DeploymentSourceSnapshot(source_kind=candidate) + │ → DeploymentPlan + │ → DeploymentBundle + │ + └─ formal release + → ReleasePlan + → VersionResolution + → ReleaseSnapshot + → ReleaseBundle + → ContractRelease + ↓ + + target + ↓ + DeploymentSourceSnapshot(source_kind=contract_release) + → DeploymentPlan + → DeploymentBundle ``` +Release approval protects formal publication. Runtime deployment permission belongs to the surrounding protected execution context; ContractRelease is provenance, not DEPLOY authorization. + For production assurance: ```text @@ -126,9 +138,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 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. 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 @@ -155,7 +167,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 release-approval boundaries, with runtime deployment permission delegated to the protected execution environment. ### Canonical ContractOps release planning @@ -177,33 +189,67 @@ 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 treats formal contract release and runtime deployment as separate lifecycles. -`semapact deployment` exposes a canonical CLI/CI surface: +A formal release is target-neutral: ```text -plan -→ DeploymentPlan +release assess +→ GovernanceDecision +→ ChangeSet +→ ReleasePlan +→ VersionResolution +→ ReleaseSnapshot +→ ReleaseBundle + +REVIEW → exact release approval + ↓ +release finalize +→ ContractRelease +→ materialize the selected version back to ODCS +``` + +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 +├── dev +├── test +└── prod +``` -preview -→ fresh runtime observation -→ DeploymentPreview +Deployment is target-specific: -execute -→ exact plan + preview + DeploymentAuthorization -→ provider execution +```text +deployment assess --release ./artifacts/contract-release.json +→ DeploymentSourceSnapshot +→ DeploymentPlan +→ fresh runtime preview +→ DeploymentBundle -verify -→ fresh observation + reconciliation +deployment deploy +→ fresh preview → execute → fresh 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. +Candidate/non-release deployment remains available directly from base + candidate and does not create a new contract version or release history. -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. +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. -See [`docs/deployment_plans.md`](docs/deployment_plans.md). +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 +semapact_contract_version +semapact_release_id +semapact_source_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: @@ -329,15 +375,82 @@ 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: +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 \ + --server development \ + --bundle-out ./artifacts/orders-dev.bundle.json +``` + +Build a target-neutral formal release separately: + +```bash +semapact release assess \ + --base ./contracts/orders.yaml \ + --candidate ./contracts/orders.candidate.yaml \ + --base-revision-ref git:abc123 \ + --candidate-revision-ref git:def456 \ + --bundle-out ./artifacts/orders.release.bundle.json +``` + +For REVIEW releases, record the exact external approval and then finalize the release: ```bash -semapact deployment plan --help -semapact deployment preview --help -semapact deployment execute --help -semapact deployment verify --help +semapact release approve \ + --bundle ./artifacts/orders.release.bundle.json \ + --actor-reference github-environment:contract-release \ + --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 ``` +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 the finalized release artifact directly: + +```bash +semapact deployment assess \ + --release ./artifacts/orders.contract-release.json \ + --server production \ + --bundle-out ./artifacts/orders-prod.deployment.bundle.json + +semapact deployment deploy \ + --bundle ./artifacts/orders-prod.deployment.bundle.json \ + --warehouse-id +``` + +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. See [`docs/configuration.md`](docs/configuration.md) for precedence and schema details. + ## Optional Dependencies | Extra | Purpose | @@ -359,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 @@ -376,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. diff --git a/docs/approval_history.md b/docs/approval_history.md index 88f3ff2d..db001917 100644 --- a/docs/approval_history.md +++ b/docs/approval_history.md @@ -102,6 +102,39 @@ record = service.record_review_action( Callers using another future persistence backend can provide the same `ApprovalHistoryRepository` capability without changing `ApprovalRecordService`. +## Release approval handoff and resolution + +Formal REVIEW approval belongs to the PUBLISH boundary, not to runtime deployment. + +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 `PUBLISH`; +- exact `ReleaseSnapshot` as `scopeReference`; +- exact `ReleaseBundle` digest in `evidenceReferences`. + +`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. + +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. + +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 `ApprovalRecord` preserves review evidence; it is not an authentication credential. 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. 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. diff --git a/docs/contractops_phases.md b/docs/contractops_phases.md index 5160c859..3c55a509 100644 --- a/docs/contractops_phases.md +++ b/docs/contractops_phases.md @@ -4,31 +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: + ```text ANALYZE → GovernanceDecision -PLAN +PLAN RELEASE → ChangeSet → ReleasePlan → VersionResolution +→ ReleaseSnapshot -AUTHORIZE -→ ContractOpsAuthorization - -APPLY -→ AppliedContractRelease - -PUBLISH -→ PublicationResult +FORMAL RELEASE +→ immutable ContractRelease DEPLOY -→ DeploymentPlan + DeploymentAuthorization -→ runtime mutation through a platform adapter +→ 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. @@ -37,11 +45,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 +64,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 ``` @@ -138,71 +148,57 @@ 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. -APPLY, PUBLISH, and DEPLOY are distinct operation scopes. Runtime DEPLOY additionally binds authorization 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. -## 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. - -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. +- does not rerun governance or version authority; +- produces a deterministic `ReleaseSnapshot` with no authorization ID. -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. +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. ## PUBLISH -PUBLISH publishes an applied contract release or release artifact. It is distinct from runtime deployment. +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. -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. +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. -ContractOps defines only a narrow publisher port: - -```text -AppliedContractRelease - ↓ -ContractReleasePublisher.publish(...) - ↓ -opaque publication reference - ↓ -PublicationResult -``` - -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 -DEPLOY mutates a runtime toward a provider-neutral `DeploymentPlan` and is separate from release publication. +DEPLOY converges one runtime toward an exact target-specific `DeploymentPlan`. The desired state comes from one immutable `DeploymentSourceSnapshot`: -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. +- `source_kind=candidate` for validation/test deployment without a formal release; +- `source_kind=contract_release` for deployment of an already-finalized `ContractRelease`. -Platform-specific execution belongs behind a deployment adapter. The adapter must not recompute governance, version authority, release planning, or approval semantics. +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. -See [`deployment_plans.md`](deployment_plans.md) for the deployment CLI, Databricks capability boundary, preview integrity checks, and convergence verification semantics. +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. -## Failure semantics +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. -ContractOps distinguishes invalid context from denied authorization: +Runtime deployment history is optional operational telemetry and is not written to Git by default. -- 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. +See [`deployment_plans.md`](deployment_plans.md) for the complete candidate/release CLI flow, operational-history backends, Databricks tags, and convergence semantics. -Unexpected publisher/runtime failures are not converted into governance decisions; they propagate as execution failures. +## Failure semantics -## Compatibility helpers +ContractOps distinguishes invalid release context from denied publication authorization: -`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. +- 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. -`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. diff --git a/docs/deployment_plans.md b/docs/deployment_plans.md index 38bb1f79..36c8ff3f 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 +Candidate contract or finalized ContractRelease + = exact governed desired state + ObservedPlatformState = point-in-time runtime state @@ -23,13 +23,11 @@ This matters because one release may require different native operations in diff The deployment planning boundary is therefore: ```text -AppliedContractRelease +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 an exact applied contract release 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. -It is built only from `AppliedContractRelease`; drafts and raw candidate contracts are not deployment authority. +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: @@ -140,124 +138,316 @@ 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. +## ReleaseBundle and DeploymentBundle boundaries + +Formal contract release and runtime deployment are separate lifecycles with separate immutable handoff artifacts. + +A formal release is target-neutral: + +```text +base + candidate + ↓ +GovernanceDecision +→ ChangeSet +→ ReleasePlan +→ VersionResolution +→ ReleaseSnapshot +→ ReleaseBundle +``` + +`ReleaseBundle` contains no runtime target, workspace, catalog, schema, warehouse, or deployment preview. It freezes exactly what is intended to become a released contract. + +For REVIEW decisions, approval binds: + +```text +PUBLISH ++ exact ReleaseSnapshot ID ++ exact ReleaseBundle digest +``` + +Finalization then creates the formal release fact and materializes the selected semantic version back into ODCS: + +```text +ReleaseBundle ++ exact approval when REVIEW + ↓ +release finalize + ↓ +ContractRelease ++ versioned ODCS contract +``` + +A finalized release is environment-neutral. One release can subsequently fan out to multiple runtime targets without another version bump: + +```text +ContractRelease orders@1.4.0 +├── dev +├── test +└── prod +``` + +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(source_kind=candidate) +→ target-specific DeploymentPlan +→ fresh DeploymentPreview +→ DeploymentBundle +``` + +Finalized-release deployment: + +```text +ContractRelease +→ DeploymentSourceSnapshot(source_kind=contract_release) +→ target-specific DeploymentPlan +→ fresh DeploymentPreview +→ DeploymentBundle +``` + +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 -The deployment CLI consumes and emits canonical JSON artifacts. Planning and preview are read-only; `execute` is the runtime mutation boundary. +### Candidate deployment -### Plan +Candidate deployment remains a direct `assess → deploy` path: ```bash -semapact deployment plan \ - --release ./artifacts/applied-release.json \ - --platform databricks \ - --runtime main.sales \ - --source-reference https://dbc-example.cloud.databricks.com +semapact deployment assess \ + --base ./contracts/orders.yaml \ + --candidate ./contracts/orders.candidate.yaml \ + --base-revision-ref git:abc123 \ + --candidate-revision-ref git:def456 \ + --server development \ + --bundle-out ./artifacts/orders-dev.deployment.bundle.json + +semapact deployment deploy \ + --bundle ./artifacts/orders-dev.deployment.bundle.json \ + --warehouse-id ``` -`--source-reference` must match the stable source identity reported by the runtime provider. Optional `--server` preserves the selected contract-server reference as target provenance. +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. -The output is the canonical `DeploymentPlan` JSON. +### Formal release -### Preview +Build the target-neutral release artifact first: ```bash -semapact deployment preview \ - --plan ./artifacts/deployment-plan.json +semapact release assess \ + --base ./contracts/orders.yaml \ + --candidate ./contracts/orders.candidate.yaml \ + --base-revision-ref git:abc123 \ + --candidate-revision-ref git:def456 \ + --bundle-out ./artifacts/orders.release.bundle.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. +For a REVIEW release, an external workflow may persist exact approval with the convenience command: -### Execute +```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 \ + --approval-out ./artifacts/orders.approval.json +``` + +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. -For a preview containing CREATE or ALTER operations, provide the SQL warehouse used for mutation: +Finalize exactly once: ```bash -semapact deployment execute \ - --plan ./artifacts/deployment-plan.json \ - --preview ./artifacts/deployment-preview.json \ - --authorization ./artifacts/deployment-authorization.json \ - --warehouse-id +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 ``` -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. +Finalization: -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. +- authorizes the exact formal release; +- writes one immutable `ContractRelease` to the Git governance ledger; +- writes the selected version back to the ODCS contract file. -Provider execution success is not convergence proof. +After finalization, hand the immutable ContractRelease artifact directly to target-specific deployment: -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. +```bash +semapact deployment assess \ + --release ./artifacts/orders.contract-release.json \ + --server production \ + --bundle-out ./artifacts/orders-prod.deployment.bundle.json +``` -### Verify +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. -```bash -semapact deployment verify \ - --plan ./artifacts/deployment-plan.json \ - --output json +### CI/CD handoff rules + +Two immutable boundaries are now explicit: + +```text +CI release planning +→ ReleaseBundle +→ approval/finalize +→ ContractRelease + +runtime planning +ContractRelease + target +→ DeploymentBundle +→ fresh CD execution ``` -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: +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. -| Runtime status | Exit code | -| --- | ---: | -| `IN_SYNC` | `0` | -| `DRIFT` | `6` | -| `INDETERMINATE` | `7` | +At execution time: -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. +```text +protected CI/CD execution boundary +→ exact DeploymentBundle +→ validate candidate/finalized-release provenance +→ fresh runtime observation +→ fresh DeploymentPreview +→ apply +→ fresh reconciliation +→ IN_SYNC / DRIFT / INDETERMINATE +``` + +## 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 +ContractRelease +``` + +`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 `ContractRelease` is created, a later runtime deployment failure does not undo or renumber the release. + +### 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 +``` + +Configure operational telemetry once in `.semapact.yaml`: + +```yaml +history: + operational: + backend: sqlite + path: .semapact/operational.db +``` + +For a shared/higher-volume Delta sink: + +```yaml +history: + operational: + backend: delta + table_uri: s3://governance/semapact/operational-history +``` + +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. + +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. ## 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 | + +Rename, existing-column type/nullability mutation, unsafe required-column addition, DROP, non-managed mutation, and ambiguous mappings fail closed. + +### Unity Catalog release provenance tags -For this slice, `ALTER` means **only additive nullable-column change**. The adapter does not interpret `ALTER` as generic schema evolution. +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: -The following are rejected rather than guessed or silently converted: +```text +semapact_contract_id +semapact_contract_version +semapact_release_id +semapact_source_revision +``` -- 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. +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. -Existing external/non-managed assets remain observable through the runtime read side, but this deployment adapter does not claim mutation authority over them. +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 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. +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. -## Authorization scope +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. +## Execution trust boundary -A `ContractOpsAuthorization(operation=DEPLOY)` establishes release-context authorization. Before runtime mutation, it must be bound to the exact `DeploymentPlan` as a `DeploymentAuthorization`. +Candidate and finalized-release deployments use different provenance, but neither turns release state into runtime execution authority: -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. +```text +candidate deployment +→ exact GovernanceDecision + candidate source snapshot +→ BLOCK fails closed + +formal release deployment +→ exact finalized ContractRelease provenance +``` -A PUBLISH authorization cannot authorize DEPLOY. +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 `deploymentPlanId` is UUID5-derived from the full stable plan record: -- exact `AppliedContractRelease` identity and provenance; +- 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 applied 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 `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. 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. 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. diff --git a/examples/README.md b/examples/README.md index 37835c01..c8111f92 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,35 +2,47 @@ 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 + production deployment for a standard + data-product repo +- `github/central-contract-repo-ci-cd.yml` + - 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 trigger -## Azure DevOps Examples +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. -- `azure-devops/semapact-pr-validation.yml` - - PR validation template -- `azure-devops/semapact-release.yml` - - release promotion template +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. -Important rules reflected by these examples: +The examples use two distinct GitHub Environment boundaries. `contract-release` supplies +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. -- 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 +`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. 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 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 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..2696b50d --- /dev/null +++ b/examples/github/central-contract-repo-ci-cd.yml @@ -0,0 +1,327 @@ +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 + +concurrency: + group: semapact-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + PYTHON_VERSION: "3.11" + CONTRACT_ROOT: contracts + DEV_SERVER: development + PROD_SERVER: production + +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 }} + 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}" \ + > 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: .artifactKey}]}' 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}" \ + --server "${DEV_SERVER}" \ + --bundle-out "artifacts/${ARTIFACT}.candidate.deployment.bundle.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) }} + 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 + + - 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 target-neutral ReleaseBundle + 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 release assess \ + --base /tmp/base-contract.yaml \ + --candidate "${CONTRACT_ROOT}/${CONTRACT_PATH}" \ + --base-revision-ref "git:${BASE_SHA}" \ + --candidate-revision-ref "git:${CANDIDATE_SHA}" \ + --bundle-out "artifacts/${ARTIFACT}.release.bundle.json" + + - name: Upload exact release bundle + uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.artifact }} + path: artifacts/ + + 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: contract-release + steps: + - name: Checkout main + 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: Download all target-neutral release bundles + uses: actions/download-artifact@v4 + with: + pattern: release-* + path: artifacts/releases + merge-multiple: true + + - name: Approve REVIEW releases, finalize all releases, and materialize versions + env: + MATRIX: ${{ needs.detect-contracts.outputs.matrix }} + run: | + 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') + 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)" \ + --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" + done + + - name: Commit all released contracts and governance facts once + run: | + 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 finalized ContractRelease artifacts + uses: actions/upload-artifact@v4 + with: + name: semapact-finalized-releases + path: artifacts/finalized/ + + deploy-production: + needs: + - detect-contracts + - 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 finalized governance ledger + 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: Download finalized ContractRelease artifacts + uses: actions/download-artifact@v4 + with: + name: semapact-finalized-releases + path: artifacts/releases + + - name: Assess finalized release against production + env: + ARTIFACT: ${{ matrix.artifact }} + run: | + mkdir -p artifacts/deploy + semapact deployment assess \ + --release "artifacts/releases/${ARTIFACT}.contract-release.json" \ + --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. diff --git a/examples/github/data-product-ci-cd.yml b/examples/github/data-product-ci-cd.yml new file mode 100644 index 00000000..4b53e963 --- /dev/null +++ b/examples/github/data-product-ci-cd.yml @@ -0,0 +1,235 @@ +name: SemaPact - data product CI/CD + +on: + pull_request: + paths: + - contract.yaml + push: + branches: + - main + paths: + - contract.yaml + +permissions: + contents: write + +concurrency: + group: semapact-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +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}" \ + --server "${DEV_SERVER}" \ + --bundle-out artifacts/candidate.deployment.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' && + !contains(github.event.head_commit.message, '[semapact-release]') + runs-on: ubuntu-latest + 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 + + - name: Materialize previous released contract + env: + BASE_SHA: ${{ github.event.before }} + run: | + git show "${BASE_SHA}:${CONTRACT_PATH}" > /tmp/base-contract.yaml + + - name: Build target-neutral formal release bundle + env: + BASE_SHA: ${{ github.event.before }} + CANDIDATE_SHA: ${{ github.sha }} + run: | + mkdir -p artifacts + semapact release assess \ + --base /tmp/base-contract.yaml \ + --candidate "${CONTRACT_PATH}" \ + --base-revision-ref "git:${BASE_SHA}" \ + --candidate-revision-ref "git:${CANDIDATE_SHA}" \ + --bundle-out artifacts/release.bundle.json \ + > artifacts/release.summary.json + + - name: Upload exact release bundle + uses: actions/upload-artifact@v4 + with: + name: semapact-release-bundle + path: artifacts/ + + 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 + # Example policy: GitHub Environment protects formal release publication. + # SemaPact only persists an ApprovalRecord when its decision is REVIEW. + environment: contract-release + steps: + - name: Checkout main + 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: Download exact release bundle + uses: actions/download-artifact@v4 + with: + name: semapact-release-bundle + path: artifacts + + - 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)" \ + --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 + + - name: Commit released contract and governance ledger once + run: | + 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 + + - name: Upload release result + uses: actions/upload-artifact@v4 + with: + name: semapact-finalized-release + path: | + artifacts/finalize.json + artifacts/contract-release.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: Download finalized release artifact + uses: actions/download-artifact@v4 + with: + name: semapact-finalized-release + path: artifacts + + - name: Assess finalized release against production + run: | + semapact deployment assess \ + --release artifacts/contract-release.json \ + --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. 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 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" - } -] diff --git a/schemas/semapact-config.schema.json b/schemas/semapact-config.schema.json new file mode 100644 index 00000000..0a939a73 --- /dev/null +++ b/schemas/semapact-config.schema.json @@ -0,0 +1,101 @@ +{ + "$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 configuration remains extensible.", + "properties": { + "operational": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "delta": "#/$defs/DeltaOperationalHistoryConfig", + "sqlite": "#/$defs/SQLiteOperationalHistoryConfig" + }, + "propertyName": "backend" + }, + "oneOf": [ + { + "$ref": "#/$defs/SQLiteOperationalHistoryConfig" + }, + { + "$ref": "#/$defs/DeltaOperationalHistoryConfig" + } + ] + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operational" + } + }, + "title": "HistoryConfig", + "type": "object" + }, + "SQLiteOperationalHistoryConfig": { + "additionalProperties": false, + "description": "Local SQLite operational history configuration.", + "properties": { + "backend": { + "const": "sqlite", + "title": "Backend", + "type": "string" + }, + "path": { + "minLength": 1, + "title": "Path", + "type": "string" + } + }, + "required": [ + "backend", + "path" + ], + "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" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "SemaPactConfigSchema", + "type": "object" +} 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", diff --git a/semapact/application/models/__init__.py b/semapact/application/models/__init__.py index 34a27143..851e6335 100644 --- a/semapact/application/models/__init__.py +++ b/semapact/application/models/__init__.py @@ -1,12 +1,16 @@ """Typed application results composed from canonical domain artifacts.""" +from semapact.application.models.deployment_workflow import ( + DeploymentBundle, + DeploymentExecutionResult, + build_deployment_bundle, + compute_deployment_bundle_digest, +) 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 @@ -16,13 +20,15 @@ __all__ = [ "BrokenHistoryReference", "ContractEvolution", - "DeploymentEvolution", + "DeploymentBundle", + "DeploymentExecutionResult", + "build_deployment_bundle", + "compute_deployment_bundle_digest", "GovernanceAnalysis", "GovernanceProposal", "HistoryIntegrityReport", "ProposalEvolution", "ReleaseEvolution", "ReleasePlanningResult", - "RuntimeEvolution", "RuntimeReconciliation", ] diff --git a/semapact/application/models/deployment_workflow.py b/semapact/application/models/deployment_workflow.py new file mode 100644 index 00000000..917a1acd --- /dev/null +++ b/semapact/application/models/deployment_workflow.py @@ -0,0 +1,199 @@ +"""Application artifacts for target-specific deployment workflows.""" + +from __future__ import annotations + +import hashlib +from typing import Literal + +from pydantic import BaseModel, ConfigDict, model_validator + +from semapact.contractops import ChangeSet, ContractRelease +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 + + +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 + contract_release_id: str | None = None + fresh_preview: DeploymentPreview + reconciliation: ReconciliationResult + status: RuntimeDriftStatus + review_preview_changed: bool + + +class DeploymentBundle(BaseModel): + """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 + 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["1"] = "1" + + @property + def is_release(self) -> bool: + return self.deployment_source.source_kind == "contract_release" + + @model_validator(mode="after") + def _validate_bundle_links(self) -> "DeploymentBundle": + source = self.deployment_source + plan = self.deployment_plan + + if self.is_release: + if self.contract_release is None: + raise ValueError( + "Release DeploymentBundle requires finalized ContractRelease" + ) + if self.decision is not None or self.change_set is not None: + raise ValueError( + "Release DeploymentBundle must not carry candidate governance artifacts" + ) + release = self.contract_release + if source.release_id != release.contract_release_id: + raise ValueError( + "DeploymentBundle source does not reference ContractRelease" + ) + if source.contract_id != release.contract_id: + raise ValueError( + "DeploymentBundle source/release contract mismatch" + ) + if source.contract_version != release.contract_version: + raise ValueError( + "DeploymentBundle source/release version mismatch" + ) + if source.revision_ref != release.source_revision_ref: + raise ValueError( + "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" + ) + if self.contract_release is not None: + raise ValueError( + "Candidate DeploymentBundle cannot contain ContractRelease" + ) + 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" + ) + 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( + "DeploymentBundle plan does not match deployment source" + ) + if plan.contract_id != source.contract_id: + raise ValueError("DeploymentBundle plan/source contract mismatch") + if plan.revision_ref != source.revision_ref: + raise ValueError("DeploymentBundle plan/source revision mismatch") + if plan.contract_version != source.contract_version: + raise ValueError("DeploymentBundle plan/source 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( + deployment_source=source, + deployment_plan=plan, + review_preview=self.review_preview, + decision=self.decision, + change_set=self.change_set, + 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( + *, + deployment_source: DeploymentSourceSnapshot, + deployment_plan: DeploymentPlan, + review_preview: DeploymentPreview, + decision: GovernanceDecision | None = None, + change_set: ChangeSet | None = None, + contract_release: ContractRelease | None = None, +) -> DeploymentBundle: + """Package exact target-specific deployment material without release planning.""" + digest = compute_deployment_bundle_digest( + deployment_source=deployment_source, + deployment_plan=deployment_plan, + review_preview=review_preview, + decision=decision, + change_set=change_set, + contract_release=contract_release, + ) + return DeploymentBundle( + bundle_digest=digest, + deployment_source=deployment_source, + deployment_plan=deployment_plan, + review_preview=review_preview, + decision=decision, + change_set=change_set, + contract_release=contract_release, + ) + + +def compute_deployment_bundle_digest( + *, + 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 = "1", +) -> str: + """Return the immutable digest for one target-specific deployment bundle.""" + payload = { + "bundle_version": bundle_version, + "deployment_source": deployment_source.model_dump(mode="json"), + "deployment_plan": deployment_plan.model_dump(mode="json"), + "review_preview": review_preview.model_dump(mode="json"), + "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 + ), + } + encoded = canonical_compact_json(payload).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" 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, ...] = () diff --git a/semapact/application/models/release.py b/semapact/application/models/release.py index fa4d4546..480cc12b 100644 --- a/semapact/application/models/release.py +++ b/semapact/application/models/release.py @@ -1,11 +1,23 @@ -"""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, + 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 @dataclass(frozen=True) @@ -16,3 +28,132 @@ 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()}" + + +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, + ) diff --git a/semapact/application/services/deployment.py b/semapact/application/services/deployment.py index fa5d62c9..5ca045f4 100644 --- a/semapact/application/services/deployment.py +++ b/semapact/application/services/deployment.py @@ -2,14 +2,13 @@ from __future__ import annotations -from semapact.contractops import AppliedContractRelease from semapact.deployment import ( DeploymentAdapter, - DeploymentAuthorization, DeploymentPlan, DeploymentPreview, + DeploymentSourceSnapshot, DeploymentTarget, - build_deployment_plan, + build_deployment_plan_from_source, ) from semapact.exceptions import ValidationError from semapact.reconciliation import ReconciliationResult @@ -20,10 +19,10 @@ class DeploymentService: def plan( self, - release: AppliedContractRelease, + source: DeploymentSourceSnapshot, target: DeploymentTarget, ) -> DeploymentPlan: - return build_deployment_plan(release, target) + return build_deployment_plan_from_source(source, target) def preview( self, @@ -34,16 +33,16 @@ def preview( _validate_component_key(adapter.key, plan.target.platform, "deployment adapter") return adapter.preview(plan) - def execute( + def apply( self, plan: DeploymentPlan, preview: DeploymentPreview, - authorization: DeploymentAuthorization, *, 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.execute(plan, preview, authorization) + adapter.apply(plan, preview) def verify( self, diff --git a/semapact/application/services/deployment_history.py b/semapact/application/services/deployment_history.py deleted file mode 100644 index ef96a50d..00000000 --- a/semapact/application/services/deployment_history.py +++ /dev/null @@ -1,191 +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.selected_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.selected_version - or release.release_plan_id != plan.release_plan_id - or release.applied_release_id != plan.applied_release_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.applied_release_id != plan.applied_release_id: - raise ValueError( - "DeploymentAuthorization does not reference the plan's applied release" - ) - 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 diff --git a/semapact/application/services/deployment_workflow.py b/semapact/application/services/deployment_workflow.py new file mode 100644 index 00000000..5abe20d8 --- /dev/null +++ b/semapact/application/services/deployment_workflow.py @@ -0,0 +1,249 @@ +"""Canonical target-specific deployment workflow orchestration.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Literal + +from open_data_contract_standard.model import OpenDataContractStandard + +from semapact.application.models.deployment_workflow import ( + DeploymentBundle, + DeploymentExecutionResult, + build_deployment_bundle, +) +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, + build_candidate_deployment_source, + build_contract_release_deployment_source, + validate_candidate_deployment_context, + validate_contract_release_deployment_context, +) +from semapact.history import ( + OperationalHistorySink, + build_operational_deployment_event, +) +from semapact.reconciliation import RuntimeDriftStatus, classify_reconciliation_status + + +class DeploymentWorkflowService: + """Assess desired state and converge one target after the external CD gate runs.""" + + def __init__( + self, + *, + governance_service: GovernanceService | None = None, + deployment_service: DeploymentService | None = None, + ) -> None: + self._governance = governance_service or GovernanceService() + self._deployment = deployment_service or DeploymentService() + + def assess( + self, + base_contract: OpenDataContractStandard, + candidate_contract: OpenDataContractStandard, + *, + base_revision_ref: str, + candidate_revision_ref: str, + target: DeploymentTarget, + adapter: DeploymentAdapter, + ) -> DeploymentBundle: + """Build an immutable candidate deployment bundle.""" + proposal = self._governance.evaluate_proposal( + base_contract, + candidate_contract, + 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( + decision=proposal.decision, + change_set=proposal.change_set, + deployment_source=source, + deployment_plan=plan, + review_preview=preview, + ) + + def assess_release( + self, + release: ContractRelease, + *, + 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( + contract_release=release, + deployment_source=source, + deployment_plan=plan, + review_preview=preview, + ) + + def deploy( + self, + bundle: DeploymentBundle, + *, + adapter: DeploymentAdapter, + operational_history: OperationalHistorySink | None = None, + ) -> DeploymentExecutionResult: + """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__}" + ) + + release_record = None + if bundle.is_release: + assert bundle.contract_release is not None + validate_contract_release_deployment_context( + bundle.deployment_plan, + bundle.deployment_source, + bundle.contract_release, + ) + release_record = bundle.contract_release + else: + assert bundle.decision is not None + validate_candidate_deployment_context( + bundle.deployment_plan, + bundle.deployment_source, + bundle.decision, + ) + + 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.apply( + bundle.deployment_plan, + fresh_preview, + adapter=adapter, + ) + reconciliation = self._deployment.verify( + bundle.deployment_plan, + adapter=adapter, + ) + status = classify_reconciliation_status(reconciliation) + + if ( + release_record is not None + and status is RuntimeDriftStatus.IN_SYNC + and isinstance(adapter, RuntimeReleaseMetadataProjector) + ): + adapter.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, + source_revision_ref=release_record.source_revision_ref, + ), + ) + except Exception as exc: + _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 + 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, + contract_release_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, + ) + _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: Literal["SUCCEEDED", "FAILED"], + 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, + ) + ) 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], 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( 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") diff --git a/semapact/application/services/release_approval.py b/semapact/application/services/release_approval.py new file mode 100644 index 00000000..a72ff07e --- /dev/null +++ b/semapact/application/services/release_approval.py @@ -0,0 +1,51 @@ +"""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: + scoped = self._scoped_records(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 _contains_conflict(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, + release_plan_id=bundle.release_plan.release_plan_id, + version_resolution_id=bundle.version_resolution.version_resolution_id, + operation=GovernanceOperation.PUBLISH, + ) + 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 + ) + + +def _contains_conflict(records: tuple[ApprovalRecord, ...]) -> bool: + return any( + record.action is not ReviewEvidenceAction.APPROVE + for record in records + ) 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") 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, ) diff --git a/semapact/application/services/release_workflow.py b/semapact/application/services/release_workflow.py new file mode 100644 index 00000000..ea7d61e9 --- /dev/null +++ b/semapact/application/services/release_workflow.py @@ -0,0 +1,139 @@ +"""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_contract_release, + build_release_bundle, +) +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 + + +class ReleaseWorkflowService: + """Assess and approve target-neutral formal contract releases.""" + + def __init__( + self, + *, + planning_service: ReleasePlanningService | None = None, + ) -> None: + self._planning = planning_service or ReleasePlanningService() + + def assess( + self, + base_contract: OpenDataContractStandard, + candidate_contract: OpenDataContractStandard, + *, + 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, + 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,), + ) + + +class ReleaseFinalizer: + """Authorize one exact ReleaseBundle and construct its ContractRelease fact.""" + + def finalize( + self, + bundle: ReleaseBundle, + *, + approval: ApprovalRecord | None = None, + ) -> ContractRelease: + """Finalize domain state without persisting or materializing projections.""" + 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 build_contract_release(bundle) diff --git a/semapact/application/services/repository_classification.py b/semapact/application/services/repository_classification.py new file mode 100644 index 00000000..65a80586 --- /dev/null +++ b/semapact/application/services/repository_classification.py @@ -0,0 +1,147 @@ +"""Repository-level contract change classification for central contract repositories.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +from pathlib import Path +from typing import Any + +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, +) -> 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) + 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, + "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, + "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)) + } + + +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() 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 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") diff --git a/semapact/contractops/__init__.py b/semapact/contractops/__init__.py index b95e5f8f..8e04024a 100644 --- a/semapact/contractops/__init__.py +++ b/semapact/contractops/__init__.py @@ -5,20 +5,16 @@ build_change_set, build_change_set_from_decision, ) -from semapact.contractops.execution import ( - ContractReleasePublisher, - apply_contract_release, - publish_contract_release, -) +from semapact.contractops.execution import build_release_snapshot from semapact.contractops.execution_models import ( - AppliedContractRelease, - PublicationResult, + ContractRelease, + 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, ) @@ -41,12 +37,11 @@ ) __all__ = [ - "AppliedContractRelease", + "ContractRelease", + "ReleaseSnapshot", "AuthorizationReason", "ChangeSet", "ContractOpsAuthorization", - "ContractReleasePublisher", - "PublicationResult", "ReleasePlan", "ReleasePrecondition", "ReviewAuthorizationEvidence", @@ -54,18 +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", ] 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, ) 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" diff --git a/semapact/contractops/execution.py b/semapact/contractops/execution.py index 93b3ec4b..c30443b9 100644 --- a/semapact/contractops/execution.py +++ b/semapact/contractops/execution.py @@ -1,43 +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 -from semapact.contractops.integrity import ( - SEMAPACT_APPLIED_RELEASE_NAMESPACE, - SEMAPACT_PUBLICATION_NAMESPACE, - compute_applied_release_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 apply_contract_release( +def build_release_snapshot( candidate_contract: OpenDataContractStandard, *, candidate_revision_ref: str, @@ -45,13 +26,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 +37,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 +72,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 +80,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, + return 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,122 +91,17 @@ 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, ) -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 + 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" diff --git a/semapact/contractops/execution_models.py b/semapact/contractops/execution_models.py index 0604dfb1..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 @@ -8,10 +8,10 @@ from semapact.contractops.models import ContractOpsModel -class AppliedContractRelease(ContractOpsModel): - """Exact released ODCS state produced by an authorized APPLY operation.""" +class ReleaseSnapshot(ContractOpsModel): + """Exact immutable released ODCS state before any external side effect.""" - applied_release_id: str + release_snapshot_id: str contract_id: str decision_id: str change_set_id: str @@ -19,11 +19,10 @@ class AppliedContractRelease(ContractOpsModel): version_resolution_id: str release_revision_ref: str selected_version: str - authorization_id: str released_contract_json: str @field_validator( - "applied_release_id", + "release_snapshot_id", "contract_id", "decision_id", "change_set_id", @@ -31,21 +30,20 @@ class AppliedContractRelease(ContractOpsModel): "version_resolution_id", "release_revision_ref", "selected_version", - "authorization_id", "released_contract_json", ) @classmethod - def _require_non_empty_text(cls, value: str) -> str: + 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) -> AppliedContractRelease: + 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 AppliedContractRelease") + 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 @@ -55,23 +53,54 @@ def to_contract(self) -> OpenDataContractStandard: return OpenDataContractStandard.model_validate_json(self.released_contract_json) -class PublicationResult(ContractOpsModel): - """Successful result of one explicitly authorized external publication.""" +class ContractRelease(ContractOpsModel): + """Finalized target-neutral formal contract release.""" - publication_id: str - applied_release_id: str - authorization_id: str - publication_reference: str + 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 + source_revision_ref: str + released_contract_json: str @field_validator( - "publication_id", - "applied_release_id", - "authorization_id", - "publication_reference", + "contract_release_id", + "contract_id", + "contract_version", + "decision_id", + "change_set_id", + "release_plan_id", + "version_resolution_id", + "release_snapshot_id", + "source_revision_ref", + "released_contract_json", ) @classmethod - def _require_publication_text(cls, value: str) -> str: + 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 + ) diff --git a/semapact/contractops/integrity.py b/semapact/contractops/integrity.py index 1b606459..794f9981 100644 --- a/semapact/contractops/integrity.py +++ b/semapact/contractops/integrity.py @@ -12,8 +12,10 @@ import uuid 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 ( + ContractRelease, + ReleaseSnapshot, +) from semapact.contractops.models import ( ChangeSet, ContractOpsAuthorization, @@ -37,11 +39,11 @@ SEMAPACT_CONTRACTOPS_AUTHORIZATION_NAMESPACE = uuid.UUID( "b6218d0c-3f9d-44a2-8d68-e3b0ee170948" ) -SEMAPACT_APPLIED_RELEASE_NAMESPACE = uuid.UUID( - "a5de2e65-aee7-48ac-9cb6-6a785f4cdf33" +SEMAPACT_RELEASE_SNAPSHOT_NAMESPACE = uuid.UUID( + "d4248396-5ec7-4e6a-a238-b2db1abf76e8" ) -SEMAPACT_PUBLICATION_NAMESPACE = uuid.UUID( - "f776fc77-b37f-43ef-bf6d-d8dcf38d264f" +SEMAPACT_CONTRACT_RELEASE_NAMESPACE = uuid.UUID( + "0f95c8c5-4957-43f7-a38c-2756605c2df6" ) @@ -60,10 +62,10 @@ 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, AppliedContractRelease): - validate_applied_release_identity(artifact) - elif isinstance(artifact, PublicationResult): - validate_publication_result_identity(artifact) + elif isinstance(artifact, ReleaseSnapshot): + validate_release_snapshot_identity(artifact) + elif isinstance(artifact, ContractRelease): + validate_contract_release_identity(artifact) def compute_change_set_id( @@ -72,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: @@ -81,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, @@ -98,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, ) @@ -202,10 +201,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) @@ -235,7 +231,7 @@ def validate_contractops_authorization_identity( ) -def compute_applied_release_id( +def compute_release_snapshot_id( *, contract_id: str, decision_id: str, @@ -244,7 +240,6 @@ def compute_applied_release_id( version_resolution_id: str, release_revision_ref: str, selected_version: str, - authorization_id: str, released_contract_json: str, ) -> str: payload = { @@ -255,51 +250,73 @@ def compute_applied_release_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, + 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(release.applied_release_id, expected, "AppliedContractRelease") + _require_identity(snapshot.release_snapshot_id, expected, "ReleaseSnapshot") -def compute_publication_id( +def compute_contract_release_id( *, - applied_release_id: str, - authorization_id: str, - publication_reference: 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, + source_revision_ref: str, + released_contract_json: str, ) -> str: + """Derive deterministic identity for one finalized formal contract release.""" return deterministic_uuid5( - SEMAPACT_PUBLICATION_NAMESPACE, + SEMAPACT_CONTRACT_RELEASE_NAMESPACE, { - "applied_release_id": applied_release_id, - "authorization_id": authorization_id, - "publication_reference": publication_reference, + "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, + "source_revision_ref": source_revision_ref, + "released_contract_json": released_contract_json, }, ) -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, +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, + source_revision_ref=release.source_revision_ref, + released_contract_json=release.released_contract_json, ) - _require_identity(result.publication_id, expected, "PublicationResult") + _require_identity(release.contract_release_id, expected, "ContractRelease") def _require_identity(actual: str, expected: str, artifact: str) -> None: diff --git a/semapact/contractops/models.py b/semapact/contractops/models.py index 1135b1d0..7e164529 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 @@ -190,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 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", ] diff --git a/semapact/core/config_schema.py b/semapact/core/config_schema.py new file mode 100644 index 00000000..d0252535 --- /dev/null +++ b/semapact/core/config_schema.py @@ -0,0 +1,101 @@ +"""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(min_length=1) + + @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(min_length=1) + + @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"), +] + + +class HistoryConfig(BaseModel): + """Typed history configuration while unrelated configuration 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", + 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 + +_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 + + +def published_config_json_schema() -> dict[str, object]: + """Return the canonical machine-readable SemaPact configuration schema.""" + return SemaPactConfigSchema.model_json_schema() 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", -] 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() diff --git a/semapact/core/lifecycle_cli.py b/semapact/core/lifecycle_cli.py index d126c3b4..8031d5b0 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, @@ -56,7 +56,6 @@ def apply_lifecycle( decision = evaluate_governance_decision( base_contract, candidate_contract, - context=context, ) enforce_governance_gate(decision, GovernanceOperation.APPLY) 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", -] diff --git a/semapact/deployment/__init__.py b/semapact/deployment/__init__.py index 8a3a7c45..e80bc177 100644 --- a/semapact/deployment/__init__.py +++ b/semapact/deployment/__init__.py @@ -1,12 +1,18 @@ -"""Provider-neutral deployment planning, orchestration and authorization boundary.""" +"""Provider-neutral deployment planning and orchestration boundary.""" -from semapact.deployment.adapters import DeploymentAdapter -from semapact.deployment.authorization import authorize_deployment +from semapact.deployment.adapters import ( + DeploymentAdapter, + RuntimeReleaseMetadata, + RuntimeReleaseMetadataProjector, +) +from semapact.deployment.provenance import ( + validate_candidate_deployment_context, + validate_contract_release_deployment_context, +) from semapact.deployment.compilers import TransitionCompiler from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, - DeploymentAuthorization, DeploymentPlan, DeploymentPreview, DeploymentTarget, @@ -14,7 +20,15 @@ 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_source, +) +from semapact.deployment.source import ( + DeploymentSourceSnapshot, + build_candidate_deployment_source, + build_contract_release_deployment_source, +) from semapact.deployment.providers import ( DeploymentExecutionConfig, NativeOperationExecutor, @@ -29,7 +43,8 @@ "DeploymentAction", "DeploymentActionKind", "DeploymentAdapter", - "DeploymentAuthorization", + "RuntimeReleaseMetadata", + "RuntimeReleaseMetadataProjector", "DeploymentExecutionConfig", "DeploymentOrchestrator", "DeploymentPlan", @@ -41,7 +56,12 @@ "NativeOperationKind", "TransitionCompiler", "SchemaTransitionPlanner", - "authorize_deployment", - "build_deployment_plan", + "validate_candidate_deployment_context", + "validate_contract_release_deployment_context", + "build_deployment_actions", + "build_deployment_plan_from_source", + "build_candidate_deployment_source", + "build_contract_release_deployment_source", + "DeploymentSourceSnapshot", "verify_deployment_convergence", ] diff --git a/semapact/deployment/adapters.py b/semapact/deployment/adapters.py index 5bfbe689..6c50dc8d 100644 --- a/semapact/deployment/adapters.py +++ b/semapact/deployment/adapters.py @@ -3,15 +3,39 @@ 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, DeploymentPlan, DeploymentPreview, ) 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 + source_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.""" @@ -33,11 +57,10 @@ def verify(self, plan: DeploymentPlan) -> ReconciliationResult: raise NotImplementedError @abstractmethod - def execute( + def apply( self, plan: DeploymentPlan, preview: DeploymentPreview, - authorization: DeploymentAuthorization, ) -> None: - """Execute only the exact authorized preview.""" + """Apply one exact preview after the application boundary authorizes execution.""" raise NotImplementedError diff --git a/semapact/deployment/authorization.py b/semapact/deployment/authorization.py deleted file mode 100644 index 70f822e0..00000000 --- a/semapact/deployment/authorization.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Bind release-level DEPLOY authorization to one exact DeploymentPlan.""" - -from __future__ import annotations - -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 AuthorizationReason, ContractOpsAuthorization -from semapact.deployment.models import ( - DeploymentAuthorization, - DeploymentPlan, - compute_deployment_authorization_id, - validate_deployment_plan_identity, -) -from semapact.exceptions import ReleaseValidationError -from semapact.governance.gate import GovernanceOperation - - -def authorize_deployment( - plan: DeploymentPlan, - release: 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): - 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__}" - ) - - validate_deployment_plan_identity(plan) - validate_applied_release_identity(release) - 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" - ) - - 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, - 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, - allowed=authorization.allowed, - ) - - -def _validate_plan_release_context( - plan: DeploymentPlan, - release: AppliedContractRelease, -) -> None: - if plan.applied_release_id != release.applied_release_id: - raise ReleaseValidationError( - "DeploymentPlan does not reference the supplied AppliedContractRelease" - ) - if plan.contract_id != release.contract_id: - raise ReleaseValidationError( - "DeploymentPlan and AppliedContractRelease 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" - ) - if plan.released_revision_ref != release.release_revision_ref: - raise ReleaseValidationError( - "DeploymentPlan released revision does not match AppliedContractRelease" - ) - if plan.selected_version != release.selected_version: - raise ReleaseValidationError( - "DeploymentPlan selected version does not match AppliedContractRelease" - ) - - -def _validate_authorization_release_context( - authorization: ContractOpsAuthorization, - release: AppliedContractRelease, -) -> None: - if authorization.decision_id != release.decision_id: - raise ReleaseValidationError( - "DEPLOY authorization decision does not match AppliedContractRelease" - ) - if authorization.change_set_id != release.change_set_id: - raise ReleaseValidationError( - "DEPLOY authorization ChangeSet does not match AppliedContractRelease" - ) - if authorization.release_plan_id != release.release_plan_id: - raise ReleaseValidationError( - "DEPLOY authorization ReleasePlan does not match AppliedContractRelease" - ) - if authorization.version_resolution_id != release.version_resolution_id: - raise ReleaseValidationError( - "DEPLOY authorization version resolution does not match AppliedContractRelease" - ) diff --git a/semapact/deployment/models.py b/semapact/deployment/models.py index a32cefe8..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 @@ -7,7 +7,13 @@ 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 ( + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) from semapact.lifecycle.identity import normalize_identity_name from semapact.utils.deterministic import deterministic_uuid5 @@ -16,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" ) @@ -119,25 +122,23 @@ def _validate_desired_state_identity(self) -> DeploymentAction: class DeploymentPlan(DeploymentModel): - """Pure deterministic runtime convergence plan for one applied release.""" + """Canonical deterministic runtime convergence plan for one exact source snapshot.""" deployment_plan_id: str - applied_release_id: str + 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"] = "2" + plan_version: Literal["1"] = "1" @field_validator( "deployment_plan_id", - "applied_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: @@ -147,12 +148,13 @@ def _require_plan_text(cls, value: str) -> str: return cleaned @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") + validate_deployment_plan_identity(self) return self @@ -216,53 +218,25 @@ def _validate_identity(self) -> DeploymentPreview: return self -class DeploymentAuthorization(DeploymentModel): - """Authorization bound to one exact DeploymentPlan and applied release.""" - - deployment_authorization_id: str - contract_ops_authorization_id: str - deployment_plan_id: str - applied_release_id: str - allowed: bool = Field(strict=True) - - @field_validator( - "deployment_authorization_id", - "contract_ops_authorization_id", - "deployment_plan_id", - "applied_release_id", - ) - @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( *, - applied_release_id: str, + 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: Sequence[DeploymentAction], - plan_version: str = "2", + plan_version: str = "1", ) -> str: + if plan_version != "1": + raise ValueError(f"Unsupported canonical DeploymentPlan version: {plan_version}") return deterministic_uuid5( SEMAPACT_DEPLOYMENT_PLAN_NAMESPACE, { - "applied_release_id": applied_release_id, + "source_snapshot_id": source_snapshot_id, "contract_id": contract_id, - "release_plan_id": release_plan_id, - "released_revision_ref": released_revision_ref, - "selected_version": selected_version, + "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, @@ -270,24 +244,6 @@ def compute_deployment_plan_id( ) -def compute_deployment_authorization_id( - *, - contract_ops_authorization_id: str, - deployment_plan_id: str, - applied_release_id: str, - allowed: bool, -) -> str: - return deterministic_uuid5( - SEMAPACT_DEPLOYMENT_AUTHORIZATION_NAMESPACE, - { - "contract_ops_authorization_id": contract_ops_authorization_id, - "deployment_plan_id": deployment_plan_id, - "applied_release_id": applied_release_id, - "allowed": allowed, - }, - ) - - def compute_deployment_preview_id( *, deployment_plan_id: str, @@ -314,11 +270,10 @@ def compute_deployment_preview_id( def validate_deployment_plan_identity(plan: DeploymentPlan) -> None: expected = compute_deployment_plan_id( - applied_release_id=plan.applied_release_id, + source_snapshot_id=plan.source_snapshot_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, target=plan.target, actions=plan.actions, plan_version=plan.plan_version, @@ -327,21 +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( - contract_ops_authorization_id=authorization.contract_ops_authorization_id, - deployment_plan_id=authorization.deployment_plan_id, - applied_release_id=authorization.applied_release_id, - allowed=authorization.allowed, - ) - 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, diff --git a/semapact/deployment/orchestrator.py b/semapact/deployment/orchestrator.py index 6e95b994..78a3d060 100644 --- a/semapact/deployment/orchestrator.py +++ b/semapact/deployment/orchestrator.py @@ -7,21 +7,21 @@ from semapact.deployment.adapters import DeploymentAdapter from semapact.deployment.compilers import TransitionCompiler 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 @@ -92,29 +92,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.applied_release_id != plan.applied_release_id: - raise ContractOpsAuthorizationError( - "DeploymentAuthorization release does not match DeploymentPlan" - ) if preview.deployment_plan_id != plan.deployment_plan_id: raise ValidationError( "DeploymentPreview is not bound to this DeploymentPlan" @@ -152,7 +143,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: @@ -170,15 +161,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 +221,6 @@ def _preview_from_observation( ), ) ) - comparison = compare_schema_snapshots( SchemaSnapshot(assets=(desired_asset,)), SchemaSnapshot(assets=observed_assets), @@ -205,46 +234,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 +284,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 +357,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 +379,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 +412,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, diff --git a/semapact/deployment/planner.py b/semapact/deployment/planner.py index 10894c1f..4e4b7367 100644 --- a/semapact/deployment/planner.py +++ b/semapact/deployment/planner.py @@ -1,11 +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 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 from semapact.deployment.models import ( DeploymentAction, DeploymentActionKind, @@ -13,31 +11,63 @@ 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 -def build_deployment_plan( - release: AppliedContractRelease, +def build_deployment_plan_from_source( + source: DeploymentSourceSnapshot, 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, AppliedContractRelease): + """Build the canonical plan from one exact deployment source snapshot.""" + if not isinstance(source, DeploymentSourceSnapshot): raise TypeError( - f"release must be AppliedContractRelease, got {type(release).__name__}" + "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__}" ) - validate_applied_release_identity(release) - contract = release.to_contract() + 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, + target=target, + actions=ordered_actions, + plan_version="1", + ) + 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, + plan_version="1", + ) + +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 an execution permission; + they become target-specific planning input only through an exact DeploymentPlan. + """ + 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} @@ -57,27 +87,7 @@ def build_deployment_plan( ) ) - ordered_actions = tuple(sorted(actions, key=lambda action: action.governed_asset)) - deployment_plan_id = compute_deployment_plan_id( - 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=target, - actions=ordered_actions, - ) - - return DeploymentPlan( - deployment_plan_id=deployment_plan_id, - 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=target, - actions=ordered_actions, - ) + return tuple(sorted(actions, key=lambda action: action.governed_asset)) def _canonical_schema_json(schema: SchemaObject) -> str: 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" + ) + + + diff --git a/semapact/deployment/source.py b/semapact/deployment/source.py new file mode 100644 index 00000000..3c60ba17 --- /dev/null +++ b/semapact/deployment/source.py @@ -0,0 +1,181 @@ +"""Exact desired-state snapshots consumed by deployment planning.""" + +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 + +from semapact.contractops import ContractRelease +from semapact.utils.deterministic import canonical_compact_json, deterministic_uuid5 + + +SEMAPACT_DEPLOYMENT_SOURCE_NAMESPACE = uuid.UUID( + "598e0c13-d9af-4cf9-a8a7-9fc7628e17c1" +) + +DeploymentSourceKind = Literal["candidate", "contract_release"] + + +class DeploymentSourceSnapshot(BaseModel): + """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_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.source_kind == "candidate": + if self.release_id is not None: + raise ValueError( + "Candidate deployment source cannot contain release provenance" + ) + 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, + contract_id=self.contract_id, + revision_ref=self.revision_ref, + contract_version=self.contract_version, + contract_json=self.contract_json, + release_id=self.release_id, + ) + if self.source_snapshot_id != expected: + raise ValueError( + "DeploymentSourceSnapshot deterministic identity does not match content" + ) + return self + + @property + def is_release(self) -> bool: + return self.source_kind != "candidate" + + 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( + source_kind="candidate", + contract_id=contract_id, + revision_ref=revision, + contract_version=contract_version, + contract_json=contract_json, + release_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, + ) + + +def build_contract_release_deployment_source( + release: ContractRelease, +) -> DeploymentSourceSnapshot: + """Project one finalized ContractRelease into deployment desired state.""" + if not isinstance(release, ContractRelease): + raise TypeError( + "release must be ContractRelease, " + f"got {type(release).__name__}" + ) + 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_id=release.contract_release_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_id=release.contract_release_id, + ) + + +def compute_deployment_source_id( + *, + source_kind: DeploymentSourceKind, + contract_id: str, + revision_ref: str, + contract_version: str, + contract_json: str, + release_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_id": release_id, + }, + ) 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, ) 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", ] 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" 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", diff --git a/semapact/governance/evaluator.py b/semapact/governance/evaluator.py index a4263af1..9356aec4 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, ) @@ -107,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, @@ -386,7 +382,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 +397,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, 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 diff --git a/semapact/governance/public.py b/semapact/governance/public.py index ca9205a2..c84c3f8a 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, @@ -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)), diff --git a/semapact/history/__init__.py b/semapact/history/__init__.py index 6ed31053..35b8e114 100644 --- a/semapact/history/__init__.py +++ b/semapact/history/__init__.py @@ -1,39 +1,24 @@ -"""Durable governance-history persistence boundary. - -History stores canonical domain artifacts without becoming a second source of -revision, governance, ContractOps, deployment, or reconciliation semantics. -""" +"""Durable governance-history persistence boundary.""" from semapact.history.models import ( ChangeSetDecisionLink, - DeploymentRecord, - DeploymentStatus, HistoryIntegrityIssueCode, HistoryStorageIntegrityIssue, - ReleaseRecord, - RuntimeObservationRecord, - RuntimeReconciliationRecord, ) from semapact.history.repository import ( ApprovalHistoryRepository, ChangeSetDecisionLinkHistoryRepository, ChangeSetHistoryRepository, + ContractReleaseHistoryRepository, ContractRevisionHistoryRepository, ContractRevisionSourceHistoryRepository, DecisionHistoryRepository, - DeploymentAuthorizationHistoryRepository, - DeploymentPlanHistoryRepository, - DeploymentPreviewHistoryRepository, - DeploymentRecordHistoryRepository, HistoryConflictError, HistoryCorruptionError, HistoryIntegrityRepository, HistoryNotFoundError, HistoryRepositoryError, ReleasePlanHistoryRepository, - ReleaseRecordHistoryRepository, - RuntimeObservationHistoryRepository, - RuntimeReconciliationHistoryRepository, ) __all__ = [ @@ -41,15 +26,10 @@ "ChangeSetDecisionLink", "ChangeSetDecisionLinkHistoryRepository", "ChangeSetHistoryRepository", + "ContractReleaseHistoryRepository", "ContractRevisionHistoryRepository", "ContractRevisionSourceHistoryRepository", "DecisionHistoryRepository", - "DeploymentAuthorizationHistoryRepository", - "DeploymentPlanHistoryRepository", - "DeploymentPreviewHistoryRepository", - "DeploymentRecord", - "DeploymentRecordHistoryRepository", - "DeploymentStatus", "HistoryConflictError", "HistoryCorruptionError", "HistoryIntegrityIssueCode", @@ -58,10 +38,21 @@ "HistoryRepositoryError", "HistoryStorageIntegrityIssue", "ReleasePlanHistoryRepository", - "ReleaseRecord", - "ReleaseRecordHistoryRepository", - "RuntimeObservationHistoryRepository", - "RuntimeObservationRecord", - "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", + ] +) diff --git a/semapact/history/integrity.py b/semapact/history/integrity.py deleted file mode 100644 index bb8e82e5..00000000 --- a/semapact/history/integrity.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Deterministic identity for history-owned audit records.""" - -from __future__ import annotations - -import uuid - -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_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" - ) diff --git a/semapact/history/models.py b/semapact/history/models.py index 2fbad67a..edc21cf5 100644 --- a/semapact/history/models.py +++ b/semapact/history/models.py @@ -6,19 +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.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): @@ -72,192 +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 - - -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") diff --git a/semapact/history/operational.py b/semapact/history/operational.py new file mode 100644 index 00000000..16082648 --- /dev/null +++ b/semapact/history/operational.py @@ -0,0 +1,194 @@ +"""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 + event_version: Literal["1"] = "1" + bundle_digest: str + 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 + 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( + "contract_release_id", + "deployment_preview_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( + event_version=self.event_version, + bundle_digest=self.bundle_digest, + 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, + 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, + contract_release_id: str | None, + contract_id: str, + contract_version: str, + revision_ref: str, + deployment_plan_id: str, + deployment_preview_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( + event_version="1", + bundle_digest=bundle_digest, + 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, + 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, + event_version="1", + bundle_digest=bundle_digest, + 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, + 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) 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:///..." + ) diff --git a/semapact/history/repository.py b/semapact/history/repository.py index a17f2408..cab60e0b 100644 --- a/semapact/history/repository.py +++ b/semapact/history/repository.py @@ -5,17 +5,12 @@ from typing import Protocol from semapact.approval.models import ApprovalRecord -from semapact.contractops import ChangeSet, ReleasePlan -from semapact.deployment import DeploymentAuthorization, DeploymentPlan, DeploymentPreview +from semapact.contractops import ChangeSet, ContractRelease, ReleasePlan 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,99 +108,17 @@ 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.""" +class ContractReleaseHistoryRepository(Protocol): + """Persistence capability for finalized formal contract release facts.""" - 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( + 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, - contract_version: str, - ) -> ReleaseRecord: ... - - -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( + ) -> tuple[ContractRelease, ...]: ... + def get_contract_release_by_version( self, contract_id: str, - ) -> tuple[RuntimeReconciliationRecord, ...]: ... - - def list_runtime_reconciliation_records_for_source( - self, - source_identifier: str, - ) -> tuple[RuntimeReconciliationRecord, ...]: ... + contract_version: str, + ) -> ContractRelease: ... diff --git a/semapact/interfaces/cli.py b/semapact/interfaces/cli.py index abc20f60..522ced02 100644 --- a/semapact/interfaces/cli.py +++ b/semapact/interfaces/cli.py @@ -275,6 +275,73 @@ 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", + ) + + 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( + "--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, + 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=".", + 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", @@ -282,7 +349,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", @@ -297,7 +363,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", @@ -305,79 +370,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) - - 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_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", - 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( @@ -443,52 +435,93 @@ def _build_parser() -> argparse.ArgumentParser: deployment_parser = subparsers.add_parser( "deployment", - help="Plan, preview, execute, and verify governed runtime deployment", + help="Assess and deploy immutable target-specific runtime bundles", ) deployment_subparsers = deployment_parser.add_subparsers( dest="deployment_command", required=True ) - deployment_plan_parser = deployment_subparsers.add_parser( - "plan", help="Build a DeploymentPlan from an exact AppliedContractRelease" + deployment_assess_parser = deployment_subparsers.add_parser( + "assess", + help=( + "Assess either a base/candidate change or an already-finalized release " + "against one fresh runtime target" + ), ) - 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_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") + 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", ) - deployment_plan_parser.add_argument("--server") - - deployment_preview_parser = deployment_subparsers.add_parser( - "preview", help="Observe runtime and derive provider-native deployment operations" + release_source_group.add_argument( + "--release-id", + help="Convenience fallback: resolve a finalized ContractRelease ID from Git history", ) - deployment_preview_parser.add_argument("--plan", required=True) - - deployment_execute_parser = deployment_subparsers.add_parser( - "execute", help="Execute an exact authorized DeploymentPreview" + deployment_assess_parser.add_argument( + "--repository-root", + default=".", + help="Repository root containing finalized release history", ) - 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_assess_parser.add_argument( + "--server", + help="Contract server identifier when the deployment source defines multiple servers", + ) + deployment_assess_parser.add_argument( + "--platform", + 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 deployment source defines no servers", + ) + deployment_assess_parser.add_argument( + "--source-reference", + 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( + "--bundle-out", + help="Write the immutable DeploymentBundle JSON artifact to this path", + ) + deployment_assess_parser.add_argument( + "--output", + choices=["text", "json"], + default="text", + help="Output format (default: text)", ) - deployment_verify_parser = deployment_subparsers.add_parser( - "verify", help="Verify DeploymentPlan convergence through M1 reconciliation" + deployment_deploy_parser = deployment_subparsers.add_parser( + "deploy", + help="Consume an immutable DeploymentBundle, execute against fresh runtime, and verify", ) - deployment_verify_parser.add_argument("--plan", required=True) - deployment_verify_parser.add_argument( + deployment_deploy_parser.add_argument("--bundle", required=True) + deployment_deploy_parser.add_argument( + "--warehouse-id", + help=( + "Databricks SQL warehouse required for runtime mutation and formal-release " + "Unity Catalog provenance tag projection" + ), + ) + deployment_deploy_parser.add_argument( + "--operational-history", + help=( + "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( "--output", choices=["text", "json"], default="text", help="Output format (default: text)", ) + return parser @@ -607,21 +640,15 @@ def main() -> int: if args.command == "deployment": from semapact.interfaces.commands.deployment_cmd import ( - run_deployment_execute, - run_deployment_plan, - run_deployment_preview, - run_deployment_verify, + run_deployment_assess, + run_deployment_deploy, ) from semapact.interfaces.outcomes import exit_code_from_outcome - if 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) + if args.deployment_command == "assess": + result = run_deployment_assess(args) + elif args.deployment_command == "deploy": + result = run_deployment_deploy(args) else: parser.error(f"Unknown deployment command: {args.deployment_command}") print(result.output) @@ -629,40 +656,35 @@ def main() -> int: if args.command == "release": from semapact.interfaces.commands.release_cmd import ( - run_release_build_manifest, + run_release_approve, + run_release_assess, 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 == "classify": - payload = run_release_classify(args) - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - if args.release_command == "plan": - payload = run_release_plan(args) + 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 == "classify-repo": - payload = run_release_classify_repo(args) + 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 == "build-manifest": - payload = run_release_build_manifest(args) + 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 == "prepare": - payload = run_release_prepare(args) + if args.release_command == "classify": + payload = run_release_classify(args) print(json.dumps(payload, indent=2, sort_keys=True)) return 0 - if args.release_command == "create-pr": - payload = run_release_create_pr(args) + if args.release_command == "plan": + payload = run_release_plan(args) print(json.dumps(payload, indent=2, sort_keys=True)) return 0 - if args.release_command == "create-prs": - payload = run_release_create_prs(args) + if args.release_command == "classify-repo": + payload = run_release_classify_repo(args) print(json.dumps(payload, indent=2, sort_keys=True)) return 0 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 diff --git a/semapact/interfaces/commands/deployment_cmd.py b/semapact/interfaces/commands/deployment_cmd.py index 26f14e6d..3f456475 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 target-specific deployment workflows.""" from __future__ import annotations @@ -12,20 +12,14 @@ from pydantic import BaseModel from pydantic import ValidationError as PydanticValidationError -from semapact.application.services.deployment import DeploymentService -from semapact.contractops import AppliedContractRelease -from semapact.deployment import ( - DeploymentAuthorization, - DeploymentPlan, - DeploymentPreview, - DeploymentTarget, -) +from semapact.application.models.deployment_workflow import DeploymentBundle +from semapact.deployment import DeploymentTarget 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 _ModelT = TypeVar("_ModelT", bound=BaseModel) @@ -33,54 +27,124 @@ @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_plan(args: argparse.Namespace) -> DeploymentCommandResult: - """Build one provider-neutral DeploymentPlan from an exact applied release.""" - release = _load_model(args.release, AppliedContractRelease) - target = DeploymentTarget( - platform=args.platform, - runtime_target=args.runtime, - source_reference=args.source_reference, - server_name=args.server, +def run_deployment_assess(args: argparse.Namespace) -> DeploymentCommandResult: + """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, ) - plan = DeploymentService().plan(release, target) - return DeploymentCommandResult( - output=_model_json(plan), - outcome=ProcessOutcome.SUCCESS, + 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 ( + create_deployment_adapter, + resolve_runtime_location, ) + service = DeploymentWorkflowService() + loader = ContractLoader(runtime_context=args.runtime_context) + + if args.release or args.release_id: + _reject_candidate_args_for_release(args) + 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 + ) + 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, + 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) -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 + if args.bundle_out: + _write_model_artifact(args.bundle_out, bundle) - adapter = create_deployment_adapter(plan.target.platform) - preview = DeploymentService().preview( - plan, - adapter=adapter, - ) - return DeploymentCommandResult( - output=_model_json(preview), - outcome=ProcessOutcome.SUCCESS, + rendered = ( + _model_json(bundle) + if args.output == "json" + else _bundle_text(bundle, artifact_path=args.bundle_out) ) + return DeploymentCommandResult(output=rendered, outcome=outcome) -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) - +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) + execution_config = None - if plan.target.platform == "databricks": + if bundle.deployment_plan.target.platform == "databricks": from semapact.platforms.databricks.deployment import ( DatabricksDeploymentExecutionConfig, ) @@ -94,52 +158,91 @@ def run_deployment_execute(args: argparse.Namespace) -> DeploymentCommandResult: ) adapter = create_deployment_adapter( - plan.target.platform, + bundle.deployment_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, - ) + from semapact.history import create_operational_history_sink -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, + operational_history = create_operational_history_sink( + _resolve_operational_history_uri(args.operational_history) + ) + result = DeploymentWorkflowService().deploy( + bundle, adapter=adapter, + operational_history=operational_history, ) - status = classify_reconciliation_status(result) rendered = ( - _verification_json(status.value, result) + _model_json(result) if args.output == "json" - else _verification_text(status.value, result) + else _deployment_result_text(result) ) return DeploymentCommandResult( output=rendered, - outcome=outcome_from_reconciliation_status(status), + outcome=outcome_from_reconciliation_status(result.status), ) +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, + } + 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), + ) + if value + ] + if supplied: + raise ValidationError( + "Release deployment 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(): + 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 _load_model(path: str, model_type: type[_ModelT]) -> _ModelT: try: raw = ( @@ -154,6 +257,12 @@ def _load_model(path: str, model_type: type[_ModelT]) -> _ModelT: ) from exc +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"), @@ -163,46 +272,93 @@ 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 _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 _bundle_text( + bundle: DeploymentBundle, + *, + artifact_path: str | None, +) -> str: + plan = bundle.deployment_plan + preview = bundle.review_preview + lines = [ + f"Contract: {bundle.deployment_source.contract_id}@" + f"{bundle.deployment_source.contract_version}", + 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.is_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: + detail += f": {operation.statement}" + lines.append(detail) + if not preview.operations: + lines.append(" - none") + lines.extend( + [ + 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) -def _verification_text(status: str, result: BaseModel) -> str: - differences = getattr(result, "differences") - unverified_paths = getattr(result, "unverified_paths") +def _deployment_result_text(result) -> str: lines = [ - f"Status: {status}", + f"Bundle digest: {result.bundle_digest}", + f"Deployment plan: {result.deployment_plan_id}", + "Execution authority: surrounding protected deployment context", + *( + [f"Contract release: {result.contract_release_id}"] + if result.contract_release_id is not None + else [] + ), + "Provider execution: SUCCEEDED", + f"Verification: {result.status.value}", ( - f"Contract: {getattr(result, 'contract_id')}@" - f"{getattr(result, 'contract_version')}" + "CI review preview changed: " + f"{'yes' if result.review_preview_changed else 'no'}" ), - f"Observation source: {getattr(result, 'observation_source_identifier')}", - f"Observation fingerprint: {getattr(result, 'observation_fingerprint')}", + "Fresh operations:", ] - 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") + 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) 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 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]: 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) diff --git a/semapact/interfaces/commands/release_cmd.py b/semapact/interfaces/commands/release_cmd.py index dd70b21e..5a93c0c1 100644 --- a/semapact/interfaces/commands/release_cmd.py +++ b/semapact/interfaces/commands/release_cmd.py @@ -11,6 +11,121 @@ ) + +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, + 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.release_approval import ReleaseApprovalResolver + 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 + + from semapact.approval import ApprovalRecord + + 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 = resolver.resolve(bundle) + + record = ReleaseFinalizer().finalize( + bundle, + 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: + _write_model_artifact(release_out, record) + return { + "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), + "releaseArtifact": release_out, + } + + +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 @@ -26,7 +141,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) @@ -64,7 +178,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, @@ -77,136 +190,16 @@ 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 ( + from semapact.application.services.repository_classification import ( classify_contracts_in_repo, 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]} - -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_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 ( - 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, - } 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 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", 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 - - # --------------------------------------------------------------------------- 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) 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 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, ) diff --git a/semapact/platforms/databricks/deployment.py b/semapact/platforms/databricks/deployment.py index 10da032d..fd013dbb 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,54 @@ def __init__( ) + def project_release_metadata( + self, + plan: DeploymentPlan, + metadata: RuntimeReleaseMetadata, + ) -> None: + """Project SemaPact release provenance into Unity Catalog table tags.""" + 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_source_revision": metadata.source_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 +209,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("'", "''") + "'" 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"] 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", + ) diff --git a/semapact/platforms/git/history_repository.py b/semapact/platforms/git/history_repository.py index dfcdaf40..b7558758 100644 --- a/semapact/platforms/git/history_repository.py +++ b/semapact/platforms/git/history_repository.py @@ -7,6 +7,7 @@ from __future__ import annotations import hashlib +import json import os import re import secrets @@ -19,36 +20,21 @@ 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.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, - DeploymentRecord, HistoryConflictError, HistoryCorruptionError, HistoryIntegrityIssueCode, HistoryNotFoundError, HistoryStorageIntegrityIssue, - ReleaseRecord, - RuntimeObservationRecord, - RuntimeReconciliationRecord, -) -from semapact.history.integrity import ( - 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, @@ -113,47 +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, -) -_DEPLOYMENT_PLANS = _HistoryKindSpec( - "deployment_plans", - DeploymentPlan, - "deployment_plan_id", - validate_deployment_plan_identity, -) -_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, -) -_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, +_CONTRACT_RELEASES = _HistoryKindSpec( + "contract_releases", + ContractRelease, + "contract_release_id", + validate_contract_release_identity, ) _HISTORY_KIND_SPECS: dict[str, _HistoryKindSpec[BaseModel]] = { @@ -166,13 +116,7 @@ class _HistoryKindSpec(Generic[T]): _CONTRACT_REVISIONS, _CONTRACT_REVISION_SOURCES, _RELEASE_PLANS, - _RELEASE_RECORDS, - _DEPLOYMENT_PLANS, - _DEPLOYMENT_PREVIEWS, - _DEPLOYMENT_AUTHORIZATIONS, - _DEPLOYMENT_RECORDS, - _RUNTIME_OBSERVATIONS, - _RUNTIME_RECONCILIATIONS, + _CONTRACT_RELEASES, ) } @@ -330,173 +274,65 @@ 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__}" + "record must be ContractRelease, " + f"got {type(record).__name__}" ) - validate_release_record_identity(record) - for existing in self.list_release_records(record.contract_id): + validate_contract_release_identity(record) + for existing in self.list_contract_releases(record.contract_id): if ( existing.contract_version == record.contract_version - and existing.release_record_id != record.release_record_id + and existing.contract_release_id != record.contract_release_id ): raise HistoryConflictError( - "A different ReleaseRecord already exists for " + "A different ContractRelease already exists for " f"{record.contract_id!r} version {record.contract_version!r}" ) - self._put(_RELEASE_RECORDS, record.release_record_id, record) + self._put(_CONTRACT_RELEASES, record.contract_release_id, record) - def get_release_record(self, release_record_id: str) -> ReleaseRecord: - return self._get(_RELEASE_RECORDS, release_record_id) + def get_contract_release( + self, + contract_release_id: str, + ) -> ContractRelease: + return self._get(_CONTRACT_RELEASES, contract_release_id) - def list_release_records(self, contract_id: str) -> tuple[ReleaseRecord, ...]: + def list_contract_releases( + self, + contract_id: str, + ) -> tuple[ContractRelease, ...]: contract_id = _required_text(contract_id, "contract_id") return tuple( record - for record in self._list(_RELEASE_RECORDS) + for record in self._list(_CONTRACT_RELEASES) if record.contract_id == contract_id ) - def get_release_record_by_version( + def get_contract_release_by_version( self, contract_id: str, contract_version: str, - ) -> ReleaseRecord: + ) -> ContractRelease: 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) + for record in self.list_contract_releases(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" + f"ContractRelease for {contract_id!r} " + f"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}" + 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() @@ -576,7 +412,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: @@ -762,7 +598,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" @@ -779,7 +615,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: @@ -808,8 +644,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: @@ -1056,6 +892,23 @@ def _path_name(path: Path) -> str: return path.name +def _canonical_artifact_json( + spec: _HistoryKindSpec[T], + artifact: T, +) -> str: + """Serialize one canonical persisted history artifact.""" + del spec + return _canonical_model_json(artifact) + + +def _parse_artifact_json( + spec: _HistoryKindSpec[T], + raw: str, +) -> T: + """Rehydrate one canonical persisted history artifact.""" + 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)) 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"] 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, + ), + ) 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", -] 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"] 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"] 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"] 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"] 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"] 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"] 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 cf3cc1c7..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 and use 'semapact release prepare'.", - "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 and use 'semapact release prepare'.", - "path": "version", - "severity": "ERROR" - } - ], - "requiredVersionBump": "major", - "schemaVersion": "1", - "validation": { - "issues": [], - "valid": true - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 - } -} 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 02d2e165..00000000 --- a/tests/fixtures/history_golden/v1/review_multi_deploy.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "fixtureVersion": "history-v1", - "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/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/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_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/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_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/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_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/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/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/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/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"] - } - }, - "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": [] - } -} diff --git a/tests/interfaces/test_cli_outcomes.py b/tests/interfaces/test_cli_outcomes.py index bad45dcf..9762f67d 100644 --- a/tests/interfaces/test_cli_outcomes.py +++ b/tests/interfaces/test_cli_outcomes.py @@ -5,8 +5,7 @@ import pytest -from semapact.change_context import ChangeContext -from semapact.core.release import RequiredBump +from semapact.versioning import RequiredBump from semapact.exceptions import ( GovernanceBlockedError, GovernanceReviewRequiredError, @@ -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), diff --git a/tests/interfaces/test_cli_subprocess_outcomes.py b/tests/interfaces/test_cli_subprocess_outcomes.py index 48ebce13..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) @@ -198,77 +192,8 @@ 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( retired_contract_yaml: Path, review_candidate_yaml: Path, tmp_path: Path @@ -362,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 @@ -382,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 diff --git a/tests/interfaces/test_deployment_cmd.py b/tests/interfaces/test_deployment_cmd.py index 7074c7be..b274741e 100644 --- a/tests/interfaces/test_deployment_cmd.py +++ b/tests/interfaces/test_deployment_cmd.py @@ -1,162 +1,106 @@ from __future__ import annotations -import json import sys -from open_data_contract_standard.model import OpenDataContractStandard, SchemaObject, SchemaProperty +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_four_explicit_phases() -> None: +def test_deployment_parser_exposes_candidate_and_finalized_release_modes() -> None: parser = cli._build_parser() - plan = parser.parse_args( + candidate = parser.parse_args( [ "deployment", - "plan", - "--release", - "release.json", - "--platform", - "databricks", - "--runtime", - "main.silver", - "--source-reference", - SOURCE_REFERENCE, + "assess", + "--base", + "base.yaml", + "--candidate", + "candidate.yaml", + "--base-revision-ref", + "git:base", + "--candidate-revision-ref", + "git:candidate", + "--server", + "development", + "--bundle-out", + "candidate.bundle.json", + "--output", + "json", ] ) - preview = parser.parse_args( - ["deployment", "preview", "--plan", "plan.json"] - ) - execute = parser.parse_args( + release = parser.parse_args( [ "deployment", - "execute", - "--plan", - "plan.json", - "--preview", - "preview.json", - "--authorization", - "authorization.json", + "assess", + "--release", + "contract-release.json", + "--server", + "production", + "--bundle-out", + "release.bundle.json", ] ) - verify = parser.parse_args( - ["deployment", "verify", "--plan", "plan.json", "--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( + deploy = parser.parse_args( [ "deployment", - "plan", - "--release", - str(release_path), - "--platform", - "databricks", - "--runtime", - "main.silver", - "--source-reference", - SOURCE_REFERENCE, - "--server", - "production", + "deploy", + "--bundle", + "bundle.json", + "--warehouse-id", + "warehouse-1", + "--operational-history", + "sqlite:///history.db", + "--output", + "json", ] ) - result = deployment_cmd.run_deployment_plan(args) - payload = json.loads(result.output) + assert candidate.deployment_command == "assess" + assert candidate.release_id is None + assert candidate.server == "development" + assert candidate.bundle_out == "candidate.bundle.json" + + assert release.deployment_command == "assess" + assert release.release == "contract-release.json" + assert release.release_id is None + assert release.repository_root == "." + assert release.server == "production" - 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" + assert deploy.deployment_command == "deploy" + assert deploy.bundle == "bundle.json" + assert deploy.warehouse_id == "warehouse-1" + assert deploy.operational_history == "sqlite:///history.db" + assert deploy.output == "json" -def test_invalid_artifact_is_a_validation_failure(tmp_path) -> None: - invalid = tmp_path / "invalid.json" - invalid.write_text("{}", encoding="utf-8") +@pytest.mark.parametrize( + "unsupported_args", + [ + ["deployment", "plan"], + ["deployment", "preview"], + ["deployment", "execute"], + ["deployment", "verify"], + ["deployment", "approve"], + ["deployment", "assess", "--release"], + ], +) +def test_only_bundle_deployment_cli_surface_is_public(unsupported_args) -> None: + parser = cli._build_parser() - with pytest.raises(ValidationError, match="Invalid DeploymentPlan artifact"): - deployment_cmd._load_model(str(invalid), deployment_cmd.DeploymentPlan) + with pytest.raises(SystemExit) as exc: + parser.parse_args(unsupported_args) + + assert exc.value.code == 2 @pytest.mark.parametrize( @@ -167,7 +111,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, @@ -175,81 +119,151 @@ 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" + 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, + }, + )() + + 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, + }, + )() + + 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( + { + "server": "production", + "type": "databricks", + "host": SOURCE_REFERENCE, + "catalog": "main", + "schema": "silver", + } + ) + assert ( + deployment_cmd._assessment_source_reference(server, None) + == SOURCE_REFERENCE + ) -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, - ), +def test_assessment_source_reference_requires_cli_fallback_without_server() -> None: + assert ( + deployment_cmd._assessment_source_reference(None, 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" + with pytest.raises(ValidationError, match="provide --source-reference"): + deployment_cmd._assessment_source_reference(None, None) + - def __init__(self) -> None: - self.verify_calls = 0 +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", + } + ) - def validate(self, plan): - raise AssertionError("CLI should call the service entrypoint") + with pytest.raises(ValidationError, match="cannot override"): + deployment_cmd._assessment_source_reference( + server, + "https://other-workspace.example", + ) - 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 test_operational_history_cli_override_takes_precedence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from semapact.core.config import config_manager - 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", - ) + monkeypatch.setattr( + config_manager, + "get", + lambda *args, **kwargs: { + "backend": "sqlite", + "path": ".semapact/from-config.db", + }, + ) - adapter = _Adapter() + assert ( + deployment_cmd._resolve_operational_history_uri( + "sqlite:///explicit.db" + ) + == "sqlite:///explicit.db" + ) - import semapact.platforms.runtime_registry as runtime_registry + +def test_operational_history_falls_back_to_typed_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from semapact.core.config import config_manager monkeypatch.setattr( - runtime_registry, - "create_deployment_adapter", - lambda platform: adapter, + config_manager, + "get", + lambda *args, **kwargs: { + "backend": "sqlite", + "path": ".semapact/operational.db", + }, ) - args = cli._build_parser().parse_args( - [ - "deployment", - "verify", - "--plan", - str(plan_path), - "--output", - "json", - ] + 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", + }, ) - 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" + with pytest.raises(ValidationError, match="history.operational"): + deployment_cmd._resolve_operational_history_uri(None) 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: diff --git a/tests/interfaces/test_release_plan_cmd.py b/tests/interfaces/test_release_plan_cmd.py index f372cc01..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", ], ) @@ -92,11 +86,92 @@ 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: + 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", + "--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", + ], + ) + + assert cli.main() == 0 + assert json.loads(capsys.readouterr().out) == expected diff --git a/tests/interfaces/test_release_workflow_cmd.py b/tests/interfaces/test_release_workflow_cmd.py new file mode 100644 index 00000000..7825f19a --- /dev/null +++ b/tests/interfaces/test_release_workflow_cmd.py @@ -0,0 +1,101 @@ +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" + 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) + + run_release_assess( + SimpleNamespace( + base=str(base), + candidate=str(candidate), + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + 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=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), + ) + ) + + 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.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() 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" diff --git a/tests/test_architecture_consolidation.py b/tests/test_architecture_consolidation.py index 6a27e5ef..2f1a1b7b 100644 --- a/tests/test_architecture_consolidation.py +++ b/tests/test_architecture_consolidation.py @@ -4,51 +4,41 @@ import uuid from datetime import date -import pytest from open_data_contract_standard.model import ( OpenDataContractStandard, SchemaObject, SchemaProperty, ) -from semapact.change_context import ChangeContext +from semapact.application.services.release_workflow import ( + ReleaseFinalizer, + ReleaseWorkflowService, +) 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 -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 +60,84 @@ 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"), 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 +148,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) + 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" 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: 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" 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, diff --git a/tests/test_cicd_workflow_regression.py b/tests/test_cicd_workflow_regression.py new file mode 100644 index 00000000..b040783d --- /dev/null +++ b/tests/test_cicd_workflow_regression.py @@ -0,0 +1,516 @@ +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( + *, + contract_id: str = "orders-product", + 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=contract_id, + name=contract_id, + 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" + approval_artifact = tmp_path / "release.approval.json" + 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", + "--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", + "--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", + "--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( + monkeypatch, + capsys, + "release", + "finalize", + "--bundle", + str(release_bundle), + "--approval", + str(approval_artifact), + "--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 + + + +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), + ) + 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" + 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" + + 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}", + "--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", + "--approval-out", + str(approval_artifact), + "--repository-root", + str(tmp_path), + ) + assert code == 0 + assert approval_artifact.is_file() + + code, finalize_output = _run_cli( + monkeypatch, + capsys, + "release", + "finalize", + "--bundle", + str(release_bundle), + "--approval", + str(approval_artifact), + "--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 diff --git a/tests/test_contractops_artifact_rehydration.py b/tests/test_contractops_artifact_rehydration.py index 64d1690b..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 ( @@ -11,27 +10,25 @@ ) from pydantic import ValidationError as PydanticValidationError -from semapact.change_context import ChangeContext +from semapact.application.models.release import ( + build_contract_release, + build_release_bundle, +) 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)) def _contract(*, name: str) -> OpenDataContractStandard: @@ -58,15 +55,10 @@ 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") - 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", @@ -80,41 +72,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 +110,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) 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", diff --git a/tests/test_contractops_execution.py b/tests/test_contractops_execution.py index 164bf309..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,25 +8,17 @@ SchemaProperty, ) -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, - publish_contract_release, + build_release_snapshot, 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)) - def _contract( *, @@ -64,16 +55,13 @@ 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) - - decision = evaluate_governance_decision(base, candidate, context=CONTEXT) + candidate = _contract( + contract_name="orders-new", + include_created_at=include_created_at, + ) + decision = evaluate_governance_decision(base, candidate) change_set = build_change_set_from_decision( decision, base_revision_ref="rev:base", @@ -90,326 +78,85 @@ 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_apply_metadata_only_release_uses_selected_patch_without_mutating_candidate() -> None: - candidate, decision, change_set, release_plan, version_resolution = _release_context( - "allow" +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) ) - 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( + first = build_release_snapshot( 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( + second = build_release_snapshot( 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.release_snapshot_id == second.release_snapshot_id + 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" +def test_release_snapshot_rejects_tampered_version_resolution() -> None: + candidate, decision, change_set, release_plan, version_resolution = ( + _release_context() ) - 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, + drifted_resolution = version_resolution.model_copy( + update={"selected_version": "v1.0.1"} ) - 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" - ) - apply_authorization = _authorization( - decision, - change_set, - release_plan, - version_resolution, - GovernanceOperation.APPLY, - ) - 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="deterministic identity"): + 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 == [] diff --git a/tests/test_contractops_golden_scenarios.py b/tests/test_contractops_golden_scenarios.py index b694cc2b..c7bc1bbb 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,32 @@ SchemaProperty, ) -from semapact.change_context import ChangeContext +from semapact.application.services.release_workflow import ( + ReleaseFinalizer, + ReleaseWorkflowService, +) 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 +58,31 @@ 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"), 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,327 +90,57 @@ 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), + 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: 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", @@ -510,8 +153,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) + 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 +183,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 == [] diff --git a/tests/test_databricks_convergence_e2e.py b/tests/test_databricks_convergence_e2e.py index 29f80bf6..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 @@ -15,28 +15,15 @@ ) from semapact.application.services.deployment import DeploymentService -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.application.services.deployment_workflow import DeploymentWorkflowService from semapact.deployment import ( DeploymentPlan, DeploymentPreview, DeploymentTarget, - authorize_deployment, -) -from semapact.deployment.models import ( - NativeOperationKind, - compute_deployment_authorization_id, + build_candidate_deployment_source, ) -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 ( @@ -51,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) @@ -95,53 +81,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( @@ -316,20 +255,17 @@ def _workflow( workspace: _StatefulWorkspace, *properties: SchemaProperty, ): - release_context = _release_context(*properties) - service = DeploymentService() - plan = service.plan(release_context.release, _target()) - authorization = authorize_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, ) @@ -343,7 +279,75 @@ def _assert_in_sync(workflow) -> None: assert result.unverified_paths == () -def test_missing_table_create_execute_and_fresh_verify_converge() -> None: +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, + 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, + 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_apply_and_fresh_verify_converge() -> None: workspace = _StatefulWorkspace(present=False) workflow = _workflow( workspace, @@ -356,10 +360,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, ) @@ -371,7 +374,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),), @@ -388,10 +391,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, ) @@ -406,7 +408,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),), @@ -422,10 +424,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, ) @@ -433,7 +434,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, @@ -448,12 +449,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 == [] @@ -479,7 +479,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", @@ -507,51 +507,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( - contract_ops_authorization_id=( - workflow.authorization.contract_ops_authorization_id - ), - deployment_plan_id=workflow.plan.deployment_plan_id, - applied_release_id=workflow.plan.applied_release_id, - 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, @@ -569,86 +534,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" - ] diff --git a/tests/test_delta_import_integration.py b/tests/test_delta_import_integration.py index 85e67d4e..162c9410 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 @@ -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" diff --git a/tests/test_deployment_databricks.py b/tests/test_deployment_databricks.py index 8e0f5662..b8d06ca4 100644 --- a/tests/test_deployment_databricks.py +++ b/tests/test_deployment_databricks.py @@ -7,19 +7,18 @@ import pytest from open_data_contract_standard.model import SchemaObject, SchemaProperty +from semapact.deployment import RuntimeReleaseMetadata 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, @@ -71,41 +70,24 @@ 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, - 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,), ) -def _authorization(plan: DeploymentPlan, allowed: bool = True) -> DeploymentAuthorization: - authorization_id = compute_deployment_authorization_id( - contract_ops_authorization_id="contractops-auth:test", - deployment_plan_id=plan.deployment_plan_id, - applied_release_id=plan.applied_release_id, - allowed=allowed, - ) - 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, - allowed=allowed, - ) - def _state( *columns: tuple[str, str, bool], @@ -283,36 +265,34 @@ 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) 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)) + adapter.apply(tampered, preview) forged_operations = ( NativeOperation( @@ -333,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"), @@ -346,25 +326,66 @@ 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)" ] -def test_no_op_execute_does_not_require_warehouse() -> 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", + source_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_source_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", + source_revision_ref="rev:released", + ), + ) + + +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"), @@ -374,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 == [] diff --git a/tests/test_deployment_history.py b/tests/test_deployment_history.py deleted file mode 100644 index 0279e699..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( - applied_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", - target=target, - actions=(), - ) - plan = DeploymentPlan( - deployment_plan_id=plan_id, - applied_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", - 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( - contract_ops_authorization_id="deploy-authorization-1", - deployment_plan_id=plan.deployment_plan_id, - applied_release_id=plan.applied_release_id, - allowed=allowed, - ) - 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, - allowed=allowed, - ) - 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) diff --git a/tests/test_deployment_orchestrator.py b/tests/test_deployment_orchestrator.py index 20b5de40..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 @@ -145,18 +143,22 @@ def _plan() -> DeploymentPlan: runtime_target="main", source_reference="source-1", ) - kwargs = dict( - applied_release_id="release-1", + identity_kwargs = dict( + 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(**kwargs), - **kwargs, + deployment_plan_id=compute_deployment_plan_id(**identity_kwargs), + source_snapshot_id="release-1", + contract_id="contract-1", + revision_ref="revision-1", + contract_version="1.0.0", + target=target, + actions=(action,), ) @@ -171,7 +173,9 @@ 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() @@ -190,21 +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( - contract_ops_authorization_id="contract-auth-1", - deployment_plan_id=plan.deployment_plan_id, - applied_release_id=plan.applied_release_id, - allowed=True, - ) - 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, - allowed=True, - ) - - orchestrator.execute(plan, preview, authorization) + orchestrator.apply(plan, preview) assert runtime_provider.observe_calls == 2 assert executor.operations == [preview.operations[0]] diff --git a/tests/test_deployment_plan.py b/tests/test_deployment_plan.py index f044f8ed..46632d18 100644 --- a/tests/test_deployment_plan.py +++ b/tests/test_deployment_plan.py @@ -10,14 +10,18 @@ ) from pydantic import ValidationError as PydanticValidationError -from semapact.contractops import AppliedContractRelease -from semapact.contractops.integrity import compute_applied_release_id +from semapact.contractops import ContractRelease +from semapact.contractops.integrity import compute_contract_release_id from semapact.deployment import ( DeploymentAction, DeploymentActionKind, + DeploymentPlan, DeploymentTarget, - build_deployment_plan, + build_candidate_deployment_source, + build_contract_release_deployment_source, + build_deployment_plan_from_source, ) +from semapact.odcs.serialization import canonical_contract_json def _schema( @@ -39,38 +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), + return ContractRelease( + contract_release_id=compute_contract_release_id(**fields), **fields, ) @@ -84,78 +91,89 @@ def _target() -> DeploymentTarget: ) -def test_same_exact_release_and_target_produce_same_plan() -> None: - release = _release(schemas=[_schema("zeta"), _schema("alpha")]) +def test_candidate_source_produces_canonical_v1_plan() -> None: + source = build_candidate_deployment_source( + _contract(), + revision_ref="rev:candidate", + ) - first = build_deployment_plan(release, _target()) - second = build_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 == "1" + 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_plan_preserves_exact_applied_release_provenance() -> 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 == "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 - plan = build_deployment_plan(release, _target()) - 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" +def test_same_exact_source_and_target_produce_same_plan() -> None: + source = build_contract_release_deployment_source( + _release(schemas=[_schema("zeta"), _schema("alpha")]) + ) + + first = build_deployment_plan_from_source(source, _target()) + second = build_deployment_plan_from_source(source, _target()) + + 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_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( 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( - _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" - 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(), + source = build_candidate_deployment_source( + _contract(schemas=[_schema("Orders")]), + 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 == "Orders" + assert plan.actions[0].physical_name == "Orders" -def test_target_is_explicit_and_changes_plan_identity() -> None: - release = _release() +def test_target_changes_plan_identity() -> None: + source = build_contract_release_deployment_source(_release()) - production = build_deployment_plan(release, _target()) - staging = build_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", @@ -165,42 +183,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: @@ -220,8 +202,11 @@ 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_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"] = "unexpected" with pytest.raises(PydanticValidationError): - setattr(plan, "selected_version", "9.9.9") + DeploymentPlan.model_validate(payload) diff --git a/tests/test_deployment_service.py b/tests/test_deployment_service.py index 6cb90a3b..cbbb0153 100644 --- a/tests/test_deployment_service.py +++ b/tests/test_deployment_service.py @@ -3,13 +3,15 @@ from datetime import datetime, timezone import pytest -from open_data_contract_standard.model import SchemaObject, SchemaProperty +from open_data_contract_standard.model import ( + SchemaObject, + SchemaProperty, +) from semapact.deployment import ( DeploymentAction, DeploymentAdapter, DeploymentActionKind, - DeploymentAuthorization, DeploymentPlan, DeploymentPreview, DeploymentTarget, @@ -17,14 +19,13 @@ NativeOperationKind, ) from semapact.deployment.models import ( - compute_deployment_authorization_id, compute_deployment_plan_id, compute_deployment_preview_id, ) 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" @@ -56,9 +57,10 @@ def verify(self, plan: DeploymentPlan) -> ReconciliationResult: self.verify_calls += 1 return self.verification_result - def execute(self, plan, preview, authorization) -> None: + def apply(self, plan, preview) -> None: self.execute_calls += 1 - self.executed = (plan, preview, authorization) + self.executed = (plan, preview, None) + def _plan() -> DeploymentPlan: @@ -87,21 +89,19 @@ 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, - 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,), ) @@ -150,7 +150,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, ) @@ -163,21 +163,6 @@ def _adapter(plan: DeploymentPlan) -> FakeDeploymentAdapter: ) -def _authorization(plan: DeploymentPlan) -> DeploymentAuthorization: - authorization_id = compute_deployment_authorization_id( - contract_ops_authorization_id="contractops-auth-1", - deployment_plan_id=plan.deployment_plan_id, - applied_release_id=plan.applied_release_id, - allowed=True, - ) - 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, - allowed=True, - ) - def test_preview_delegates_to_unified_adapter_entrypoint() -> None: plan = _plan() @@ -190,21 +175,20 @@ def test_preview_delegates_to_unified_adapter_entrypoint() -> None: assert adapter.execute_calls == 0 -def test_execute_delegates_exact_canonical_artifacts() -> None: +def test_apply_delegates_exact_plan_and_preview_without_internal_authorization() -> None: plan = _plan() preview = _preview(plan) - authorization = _authorization(plan) adapter = _adapter(plan) - DeploymentService().execute( + DeploymentService().apply( plan, preview, - authorization, adapter=adapter, ) 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: @@ -217,7 +201,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"]) def test_service_rejects_adapter_platform_mismatch(operation: str) -> None: plan = _plan() adapter = _adapter(plan) @@ -229,12 +213,7 @@ def test_service_rejects_adapter_platform_mismatch(operation: str) -> None: elif operation == "verify": DeploymentService().verify(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 diff --git a/tests/test_deployment_verification.py b/tests/test_deployment_verification.py index c01a2bad..bf7c859c 100644 --- a/tests/test_deployment_verification.py +++ b/tests/test_deployment_verification.py @@ -82,21 +82,19 @@ 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, - 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,), ) @@ -194,7 +192,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())) diff --git a/tests/test_deployment_workflow_service.py b/tests/test_deployment_workflow_service.py new file mode 100644 index 00000000..ccc1ef02 --- /dev/null +++ b/tests/test_deployment_workflow_service.py @@ -0,0 +1,461 @@ +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.services.deployment_workflow import DeploymentWorkflowService +from semapact.application.services.release_workflow import ReleaseFinalizer, ReleaseWorkflowService +from semapact.deployment import ( + DeploymentAdapter, + DeploymentPreview, + DeploymentTarget, + NativeOperation, + NativeOperationKind, +) +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.reconciliation import ReconciliationResult, RuntimeDriftStatus + + +class _PreviewAdapter(DeploymentAdapter): + key = "fake" + + def __init__(self) -> None: + self.preview_calls = 0 + + 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, 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="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 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") + + def verify(self, plan) -> ReconciliationResult: + raise AssertionError("CI bundle construction must not verify deployment") + + +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 + self.metadata_calls = [] + + 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 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.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 + return ReconciliationResult( + contract_id=plan.contract_id, + contract_version=plan.contract_version, + observation_source_identifier=plan.target.source_reference, + observation_fingerprint="obs-v2:sha256:verified", + ) + + +class _OperationalSink: + def __init__(self) -> None: + self.events = [] + + def record_deployment(self, event) -> None: + self.events.append(event) + + +class _FailingExecutionAdapter(_ExecutionAdapter): + def apply(self, plan, preview) -> None: + raise RuntimeError("provider mutation failed") + + +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", + id="orders-product", + name=name, + version="1.2.3", + status="active", + schema=[SchemaObject(name="orders", properties=properties)], + ) + + +def _target(name: str = "sales") -> DeploymentTarget: + return DeploymentTarget( + platform="fake", + runtime_target=f"main.{name}", + source_reference=f"runtime:{name}", + ) + + +def _finalized_release(): + workflow = ReleaseWorkflowService() + bundle = workflow.assess( + _contract(name="Orders"), + _contract(name="Orders", include_created_at=True), + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + ) + approval = workflow.approve( + bundle, + actor_reference="human:reviewer", + recorded_at=datetime(2026, 9, 20, 2, tzinfo=timezone.utc), + ) + return ReleaseFinalizer().finalize( + bundle, + approval=approval, + ) + + +def test_candidate_assessment_does_not_calculate_release_version() -> None: + bundle = DeploymentWorkflowService().assess( + _contract(name="Orders old"), + _contract(name="Orders new"), + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=_target(), + adapter=_PreviewAdapter(), + ) + + 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.source_kind == "candidate" + assert bundle.deployment_source.contract_version == "1.2.3" + assert bundle.deployment_plan.plan_version == "1" + + +def test_finalized_release_assessment_binds_exact_release_record(tmp_path) -> None: + release = _finalized_release() + bundle = DeploymentWorkflowService().assess_release( + release, + target=_target(), + adapter=_PreviewAdapter(), + ) + + assert bundle.is_release is True + 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 not hasattr(bundle.deployment_plan, "release_id") + + +def test_same_candidate_inputs_produce_same_bundle_digest() -> None: + service = DeploymentWorkflowService() + kwargs = dict( + 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 = service.assess( + _contract(name="Orders old"), + _contract(name="Orders new"), + adapter=_PreviewAdapter(), + **kwargs, + ) + assert first == second + assert first.bundle_digest == second.bundle_digest + + +def test_bundle_rehydration_fails_closed_when_digest_is_tampered() -> None: + bundle = DeploymentWorkflowService().assess( + _contract(name="Orders old"), + _contract(name="Orders new"), + 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"): + 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( + _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 bundle.decision is not None + assert bundle.decision.decision is DecisionResult.REVIEW + + result = service.deploy(bundle, adapter=_ExecutionAdapter()) + + assert result.contract_release_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"), + _contract(name="Orders new"), + base_revision_ref="git:base", + candidate_revision_ref="git:candidate", + target=_target(), + adapter=_PreviewAdapter(), + ) + 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 + + +def test_configured_operational_history_records_failed_candidate_deployment() -> None: + service = DeploymentWorkflowService() + bundle = service.assess( + _contract(name="Orders old"), + _contract(name="Orders new"), + 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.contract_release_id is None + assert not hasattr(event, "deployment_authorization_id") + assert event.reconciliation_status is None + + +def test_one_finalized_release_fans_out_to_multiple_targets(tmp_path) -> None: + release = _finalized_release() + service = DeploymentWorkflowService() + + dev = service.assess_release( + release, + target=_target("dev"), + adapter=_PreviewAdapter(), + ) + prod = service.assess_release( + release, + target=_target("prod"), + adapter=_PreviewAdapter(), + ) + + 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 + + +def test_release_deployment_projects_finalized_version_after_in_sync(tmp_path) -> None: + release = _finalized_release() + bundle = DeploymentWorkflowService().assess_release( + release, + target=_target(), + adapter=_PreviewAdapter(), + ) + adapter = _ExecutionAdapter() + result = DeploymentWorkflowService().deploy( + bundle, + adapter=adapter, + ) + + assert result.status is RuntimeDriftStatus.IN_SYNC + assert result.contract_release_id == release.contract_release_id + 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 not hasattr(plan, "release_id") diff --git a/tests/test_evolution_chain.py b/tests/test_evolution_chain.py deleted file mode 100644 index 47bd700a..00000000 --- a/tests/test_evolution_chain.py +++ /dev/null @@ -1,297 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timedelta, 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.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.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: - 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=version, - status="active", - schema=[SchemaObject(name="orders", properties=properties)], - ) - - -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 _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 _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, - ) - - -def test_reconstructs_complete_governance_evolution_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, - ) - - 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 _service(backend).reconstruct("orders-product") == chain - - -def test_incomplete_changeset_history_does_not_fabricate_downstream_stages(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_paths = [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 == () - - -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 - ) - - -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 == () diff --git a/tests/test_fail_closed_governance_integration.py b/tests/test_fail_closed_governance_integration.py index 0d34c5a4..0fe29c69 100644 --- a/tests/test_fail_closed_governance_integration.py +++ b/tests/test_fail_closed_governance_integration.py @@ -15,18 +15,15 @@ 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.change_context import ChangeContext from semapact.governance import ( - ChangeContext, GovernanceOperation, evaluate_governance_decision, ) 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): @@ -426,24 +291,11 @@ 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" 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( @@ -459,7 +311,7 @@ def test_breaking_change_review_behavior(tmp_path, capsys): 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" @@ -514,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" @@ -544,36 +396,13 @@ 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 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.""" @@ -587,11 +416,10 @@ 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), - context=TEST_CONTEXT, ) blocked_change = next(c for c in changes if c.contract_repo_path == "blocked.yaml") assert blocked_change.status == "blocked" @@ -604,6 +432,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] diff --git a/tests/test_governance_analysis_boundary.py b/tests/test_governance_analysis_boundary.py index 82ebf0f2..2f33d5f1 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", @@ -157,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 @@ -180,9 +179,8 @@ def test_governance_service_evaluate_preserves_analysis_boundary( decision = GovernanceService().evaluate( base, candidate, - effective_date=TEST_CONTEXT.effective_date, ) - assert decision.context == TEST_CONTEXT + assert not hasattr(decision, "context") assert base == base_before assert candidate == candidate_before diff --git a/tests/test_governance_change_integration.py b/tests/test_governance_change_integration.py index dc636150..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,8 +16,7 @@ Server, ) -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 ( @@ -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( 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() 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), diff --git a/tests/test_governance_golden_scenarios.py b/tests/test_governance_golden_scenarios.py index 56c22e04..f21f690f 100644 --- a/tests/test_governance_golden_scenarios.py +++ b/tests/test_governance_golden_scenarios.py @@ -15,8 +15,7 @@ import yaml 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, @@ -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) @@ -344,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) @@ -363,7 +358,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, ) @@ -394,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 # ============================================================================== @@ -417,7 +416,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 +430,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 +444,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 +460,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 +481,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 +531,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 +602,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 +631,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,12 +664,20 @@ 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" - 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("*"): 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" 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") diff --git a/tests/test_governance_service.py b/tests/test_governance_service.py index b45a4208..0ecc1fd7 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: @@ -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" 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 - ) diff --git a/tests/test_history_integrity.py b/tests/test_history_integrity.py index e9f6cb46..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, ) @@ -76,9 +72,6 @@ def _evolution_service(backend: GitWorkingTreeHistoryRepository) -> EvolutionCha decisions=backend, decision_links=backend, releases=backend, - deployments=backend, - observations=backend, - runtime_reconciliations=backend, ) 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: diff --git a/tests/test_identity_integration.py b/tests/test_identity_integration.py index 238c4856..480285e8 100644 --- a/tests/test_identity_integration.py +++ b/tests/test_identity_integration.py @@ -3,9 +3,9 @@ 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.change_context import ChangeContext from semapact.lifecycle.merge_engine import ContractMergeEngine from semapact.lifecycle.policy import evaluate_merge_policy 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( diff --git a/tests/test_lifecycle_fail_closed_governance.py b/tests/test_lifecycle_fail_closed_governance.py index 86ce0310..2eaac1e0 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)) @@ -62,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 @@ -78,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 @@ -94,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 @@ -110,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 @@ -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 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", diff --git a/tests/test_operational_history.py b/tests/test_operational_history.py new file mode 100644 index 00000000..a19d386b --- /dev/null +++ b/tests/test_operational_history.py @@ -0,0 +1,64 @@ +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), + 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", + 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() + assert event.event_version == "1" + 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) diff --git a/tests/test_operational_history_config.py b/tests/test_operational_history_config.py new file mode 100644 index 00000000..3ae13fc1 --- /dev/null +++ b/tests/test_operational_history_config.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from pathlib import Path +import pytest +from pydantic import ValidationError as PydanticValidationError + +from semapact.core.config_schema import ( + DeltaOperationalHistoryConfig, + SQLiteOperationalHistoryConfig, + SemaPactConfigSchema, + operational_history_uri_from_config, + parse_operational_history_config, + published_config_json_schema, +) + + +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) + + +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 + + +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") + ) + + assert schema == published_config_json_schema() 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) diff --git a/tests/test_public_governance_decision.py b/tests/test_public_governance_decision.py index 4d74e830..f807084a 100644 --- a/tests/test_public_governance_decision.py +++ b/tests/test_public_governance_decision.py @@ -2,8 +2,6 @@ from __future__ import annotations -from datetime import date -from pathlib import Path import pytest from pydantic import ValidationError as PydanticValidationError from open_data_contract_standard.model import ( @@ -13,9 +11,7 @@ SchemaProperty, ) -from semapact.change_context import ChangeContext from semapact.governance import ( - PublicChangeContextV1, PublicChangeEvidenceV1, PublicGovernanceChangeEvidenceV1, PublicGovernanceChangeV1, @@ -29,10 +25,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 +72,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 +81,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 +99,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 +114,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 +143,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 +165,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 +185,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 +199,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 +229,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 +239,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 +253,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 +269,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 +296,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 +310,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 +324,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 +359,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 +381,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 +404,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) @@ -432,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, context=TEST_CONTEXT) - 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, context=TEST_CONTEXT) - 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, context=TEST_CONTEXT) - 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, context=TEST_CONTEXT) - 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, context=TEST_CONTEXT) - 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 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: 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, - ) 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 diff --git a/tests/test_release_planning_service.py b/tests/test_release_planning_service.py index 0211bcdb..296cdb83 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: @@ -69,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", ) @@ -98,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", ) diff --git a/tests/test_release_workflow_service.py b/tests/test_release_workflow_service.py new file mode 100644 index 00000000..40bb73d0 --- /dev/null +++ b/tests/test_release_workflow_service.py @@ -0,0 +1,170 @@ +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.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 + + +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), + 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() + finalizer = ReleaseFinalizer() + + with pytest.raises(ContractOpsAuthorizationError, match="requires approval"): + finalizer.finalize(bundle) + + 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 = finalizer.finalize( + bundle, + 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 + assert record.source_revision_ref == "git:candidate" + 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 + + + +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 diff --git a/tests/test_retired_immutability.py b/tests/test_retired_immutability.py index a2fcdb21..15220270 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 @@ -106,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 @@ -122,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 @@ -131,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.""" @@ -163,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 @@ -182,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 @@ -206,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 @@ -229,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 @@ -246,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 @@ -263,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 @@ -278,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 @@ -292,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 @@ -312,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 @@ -333,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 @@ -492,72 +469,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 +520,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 # ============================================================================== @@ -661,7 +546,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) @@ -682,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) 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, - ) diff --git a/tests/test_semapact_cli_readable.py b/tests/test_semapact_cli_readable.py index d2377d8b..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, ], ) @@ -336,58 +334,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 @@ -414,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, ], ) @@ -429,245 +373,4 @@ 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( - 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 diff --git a/tests/test_semapact_devops_examples_readable.py b/tests/test_semapact_devops_examples_readable.py index 8687bab2..6c24e9cf 100644 --- a/tests/test_semapact_devops_examples_readable.py +++ b/tests/test_semapact_devops_examples_readable.py @@ -1,42 +1,68 @@ 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") +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_example - assert "release build-manifest" in release_example - assert "release create-prs" in release_example + 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_azure_devops_examples_reference_release_commands(): - pr_pipeline = Path("examples/azure-devops/semapact-pr-validation.yml").read_text( +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" ) - release_pipeline = Path("examples/azure-devops/semapact-release.yml").read_text( + + 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" not in workflow + assert "deployment deploy" in workflow + assert "environment: contract-release" in workflow + assert "environment: production" in workflow + assert "--operational-history" 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 pr_pipeline - assert "release build-manifest" in release_pipeline - assert "release create-prs" in release_pipeline + 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 + assert "finalize-releases:" in workflow + assert "deployment assess" in workflow + assert "--release " in workflow + assert "--release-id" not in workflow + assert "deployment deploy" in workflow + assert "semapact-finalized-releases" 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"] diff --git a/tests/test_semapact_devops_readable.py b/tests/test_semapact_devops_readable.py index 6e4e949c..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.governance 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) 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" 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" 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( 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 diff --git a/tests/test_semapact_merge_engine.py b/tests/test_semapact_merge_engine.py index 6f6b37b7..d1c2de5f 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 @@ -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 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( 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") - - 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