diff --git a/semapact/governance/__init__.py b/semapact/governance/__init__.py index 2f023bd..bd9be77 100644 --- a/semapact/governance/__init__.py +++ b/semapact/governance/__init__.py @@ -23,6 +23,26 @@ enforce_governance_gate, evaluate_governance_gate, ) +from semapact.governance.public import ( + PublicChangeContextV1, + PublicChangeDomain, + PublicChangeEvidenceV1, + PublicChangeType, + PublicDecisionResult, + PublicEntityType, + PublicEvidenceSource, + PublicGovernanceChangeEvidenceV1, + PublicGovernanceChangeV1, + PublicGovernanceDecisionV1, + PublicGovernanceModel, + PublicGovernanceReasonV1, + PublicPolicyOutcomeV1, + PublicRequiredVersionBump, + PublicSeverity, + PublicValidationOutcomeV1, + serialize_public_governance_decision, + to_public_governance_decision, +) __all__ = [ "ChangeContext", @@ -42,4 +62,22 @@ "evaluate_governance_decision", "evaluate_governance_gate", "enforce_governance_gate", + "PublicGovernanceModel", + "PublicDecisionResult", + "PublicRequiredVersionBump", + "PublicSeverity", + "PublicChangeType", + "PublicEntityType", + "PublicChangeDomain", + "PublicEvidenceSource", + "PublicChangeContextV1", + "PublicGovernanceReasonV1", + "PublicValidationOutcomeV1", + "PublicPolicyOutcomeV1", + "PublicChangeEvidenceV1", + "PublicGovernanceChangeEvidenceV1", + "PublicGovernanceChangeV1", + "PublicGovernanceDecisionV1", + "to_public_governance_decision", + "serialize_public_governance_decision", ] diff --git a/semapact/governance/models.py b/semapact/governance/models.py index d8fa3e6..ecf2b30 100644 --- a/semapact/governance/models.py +++ b/semapact/governance/models.py @@ -110,3 +110,4 @@ def _validate_allow_invariants(self) -> GovernanceDecision: f"ALLOW decision invariant violation: evidence.merge_conflicts_count must be 0, got {self.evidence.merge_conflicts_count}" ) return self + diff --git a/semapact/governance/public.py b/semapact/governance/public.py new file mode 100644 index 0000000..ca9205a --- /dev/null +++ b/semapact/governance/public.py @@ -0,0 +1,459 @@ +"""Stable, versioned public contract for SemaPact governance decisions (v1).""" + +from __future__ import annotations + +import json +from typing import Any, Literal +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, JsonValue + +from semapact.core.release import RequiredBump +from semapact.governance.models import ( + DecisionResult, + GovernanceDecision, + GovernanceReason, +) +from semapact.governance_codes import GovernanceReasonCode, GovernanceSeverity +from semapact.lifecycle.changes import ( + GovernanceChange, + GovernanceChangeDomain, + GovernanceChangeEvidence, + GovernanceChangeEvidenceSource, + GovernanceChangeType, + GovernanceEntityType, +) + + +# ============================================================================== +# Public Protocol Vocabulary Literals +# ============================================================================== + +PublicDecisionResult = Literal["ALLOW", "REVIEW", "BLOCK"] +PublicRequiredVersionBump = Literal["none", "minor", "major"] +PublicSeverity = Literal["ERROR", "WARNING", "INFO"] +PublicChangeType = Literal["ADD", "REMOVE", "MODIFY", "DEPRECATE"] +PublicEntityType = Literal[ + "CONTRACT", + "SCHEMA", + "PROPERTY", + "RELATIONSHIP", + "QUALITY", +] +PublicChangeDomain = Literal[ + "IDENTITY", + "VERSION", + "LIFECYCLE", + "STRUCTURE", + "RELATIONSHIP", + "QUALITY", + "METADATA", +] +PublicEvidenceSource = Literal["MERGE_CONFLICT"] + + +# ============================================================================== +# Protocol Mappers (Internal Domain Enum -> Public Protocol Literal) +# ============================================================================== + +_DECISION_MAP: dict[DecisionResult, PublicDecisionResult] = { + DecisionResult.ALLOW: "ALLOW", + DecisionResult.REVIEW: "REVIEW", + DecisionResult.BLOCK: "BLOCK", +} + +_SEVERITY_MAP: dict[GovernanceSeverity, PublicSeverity] = { + GovernanceSeverity.ERROR: "ERROR", + GovernanceSeverity.WARNING: "WARNING", + GovernanceSeverity.INFO: "INFO", +} + +_CHANGE_TYPE_MAP: dict[GovernanceChangeType, PublicChangeType] = { + GovernanceChangeType.ADD: "ADD", + GovernanceChangeType.REMOVE: "REMOVE", + GovernanceChangeType.MODIFY: "MODIFY", + GovernanceChangeType.DEPRECATE: "DEPRECATE", +} + +_ENTITY_TYPE_MAP: dict[GovernanceEntityType, PublicEntityType] = { + GovernanceEntityType.CONTRACT: "CONTRACT", + GovernanceEntityType.SCHEMA: "SCHEMA", + GovernanceEntityType.PROPERTY: "PROPERTY", + GovernanceEntityType.RELATIONSHIP: "RELATIONSHIP", + GovernanceEntityType.QUALITY: "QUALITY", +} + +_DOMAIN_MAP: dict[GovernanceChangeDomain, PublicChangeDomain] = { + GovernanceChangeDomain.IDENTITY: "IDENTITY", + GovernanceChangeDomain.VERSION: "VERSION", + GovernanceChangeDomain.LIFECYCLE: "LIFECYCLE", + GovernanceChangeDomain.STRUCTURE: "STRUCTURE", + GovernanceChangeDomain.RELATIONSHIP: "RELATIONSHIP", + GovernanceChangeDomain.QUALITY: "QUALITY", + GovernanceChangeDomain.METADATA: "METADATA", +} + +_EVIDENCE_SOURCE_MAP: dict[GovernanceChangeEvidenceSource, PublicEvidenceSource] = { + GovernanceChangeEvidenceSource.MERGE_CONFLICT: "MERGE_CONFLICT", +} + +_REQUIRED_BUMP_MAP: dict[RequiredBump, PublicRequiredVersionBump] = { + "none": "none", + "minor": "minor", + "major": "major", +} + + +def _map_decision(decision: DecisionResult) -> PublicDecisionResult: + try: + return _DECISION_MAP[decision] + except KeyError: + raise ValueError(f"Unsupported internal DecisionResult: {decision!r}") from None + + +def _map_severity(severity: GovernanceSeverity) -> PublicSeverity: + try: + return _SEVERITY_MAP[severity] + except KeyError: + raise ValueError(f"Unsupported internal GovernanceSeverity: {severity!r}") from None + + +def _map_change_type(change_type: GovernanceChangeType) -> PublicChangeType: + try: + return _CHANGE_TYPE_MAP[change_type] + except KeyError: + raise ValueError(f"Unsupported internal GovernanceChangeType: {change_type!r}") from None + + +def _map_entity_type(entity_type: GovernanceEntityType) -> PublicEntityType: + try: + return _ENTITY_TYPE_MAP[entity_type] + except KeyError: + raise ValueError(f"Unsupported internal GovernanceEntityType: {entity_type!r}") from None + + +def _map_domain(domain: GovernanceChangeDomain) -> PublicChangeDomain: + try: + return _DOMAIN_MAP[domain] + except KeyError: + raise ValueError(f"Unsupported internal GovernanceChangeDomain: {domain!r}") from None + + +def _map_evidence_source(source: GovernanceChangeEvidenceSource) -> PublicEvidenceSource: + try: + return _EVIDENCE_SOURCE_MAP[source] + except KeyError: + raise ValueError(f"Unsupported internal GovernanceChangeEvidenceSource: {source!r}") from None + + +def _map_required_bump(bump: RequiredBump) -> PublicRequiredVersionBump: + try: + return _REQUIRED_BUMP_MAP[bump] + except KeyError: + raise ValueError(f"Unsupported internal RequiredBump: {bump!r}") from None + + +# ============================================================================== +# Public Models +# ============================================================================== + +class PublicGovernanceModel(BaseModel): + """Shared base model for all public governance models. + + Enforces immutability (frozen=True), forbids unknown fields (extra="forbid"), + and allows populating by python attribute name or JSON alias (populate_by_name=True). + """ + + model_config = ConfigDict( + frozen=True, + extra="forbid", + populate_by_name=True, + ) + + +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.""" + + code: str + severity: PublicSeverity + message: str + path: str | None = None + details: dict[str, Any] = Field(default_factory=dict) + + +class PublicValidationOutcomeV1(PublicGovernanceModel): + """Public schema and quality validation outcome.""" + + valid: bool + issues: tuple[PublicGovernanceReasonV1, ...] = () + + +class PublicPolicyOutcomeV1(PublicGovernanceModel): + """Public lifecycle policy outcome.""" + + valid: bool + id_violation: bool = Field( + default=False, + validation_alias=AliasChoices("id_violation", "idViolation"), + serialization_alias="idViolation", + ) + version_violation: bool = Field( + default=False, + validation_alias=AliasChoices("version_violation", "versionViolation"), + serialization_alias="versionViolation", + ) + retired_violation: bool = Field( + default=False, + validation_alias=AliasChoices("retired_violation", "retiredViolation"), + serialization_alias="retiredViolation", + ) + violations: tuple[PublicGovernanceReasonV1, ...] = () + + +class PublicChangeEvidenceV1(PublicGovernanceModel): + """Public summarized evidence for contract mutations.""" + + has_changes: bool = Field( + default=False, + validation_alias=AliasChoices("has_changes", "hasChanges"), + serialization_alias="hasChanges", + ) + merge_conflicts_count: int = Field( + default=0, + validation_alias=AliasChoices("merge_conflicts_count", "mergeConflictsCount"), + serialization_alias="mergeConflictsCount", + ) + + +class PublicGovernanceChangeEvidenceV1(PublicGovernanceModel): + """Public evidence source supporting a semantic change.""" + + source: PublicEvidenceSource + code: str + + +class PublicGovernanceChangeV1(PublicGovernanceModel): + """Public canonical representation of a single semantic contract change.""" + + change_type: PublicChangeType = Field( + validation_alias=AliasChoices("change_type", "changeType"), + serialization_alias="changeType", + ) + entity_type: PublicEntityType = Field( + validation_alias=AliasChoices("entity_type", "entityType"), + serialization_alias="entityType", + ) + identity: tuple[str, ...] + path: str + field: str | None = None + before: JsonValue | None = None + after: JsonValue | None = None + domain: PublicChangeDomain + breaking: bool = False + reason_codes: tuple[str, ...] = Field( + default=(), + validation_alias=AliasChoices("reason_codes", "reasonCodes"), + serialization_alias="reasonCodes", + ) + evidence: tuple[PublicGovernanceChangeEvidenceV1, ...] = () + + +class PublicGovernanceDecisionV1(PublicGovernanceModel): + """Authoritative, versioned public contract for a SemaPact governance decision.""" + + schema_version: Literal["1"] = Field( + default="1", + validation_alias=AliasChoices("schema_version", "schemaVersion"), + serialization_alias="schemaVersion", + ) + decision_id: str = Field( + validation_alias=AliasChoices("decision_id", "decisionId"), + serialization_alias="decisionId", + ) + decision: PublicDecisionResult + contract_id: str = Field( + 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"), + serialization_alias="requiredVersionBump", + ) + reason_codes: tuple[str, ...] = Field( + default=(), + validation_alias=AliasChoices("reason_codes", "reasonCodes"), + serialization_alias="reasonCodes", + ) + reasons: tuple[PublicGovernanceReasonV1, ...] = () + validation: PublicValidationOutcomeV1 + policy: PublicPolicyOutcomeV1 + evidence: PublicChangeEvidenceV1 + changes: tuple[PublicGovernanceChangeV1, ...] = () + + @classmethod + def from_domain(cls, decision: GovernanceDecision) -> PublicGovernanceDecisionV1: + """Construct PublicGovernanceDecisionV1 from an internal GovernanceDecision domain model.""" + return to_public_governance_decision(decision) + + def to_canonical_json(self, *, indent: int | None = None) -> str: + """Serialize to deterministic canonical JSON with sorted keys.""" + return serialize_public_governance_decision(self, indent=indent) + + def to_canonical_dict(self) -> dict[str, Any]: + """Convert to JSON-compatible dictionary with external camelCase field names.""" + return self.model_dump(mode="json", by_alias=True) + + +# ============================================================================== +# Explicit Projection Functions +# ============================================================================== + +def _project_reason(reason: GovernanceReason) -> PublicGovernanceReasonV1: + """Project internal GovernanceReason to PublicGovernanceReasonV1.""" + code_val = reason.code.value if isinstance(reason.code, GovernanceReasonCode) else str(reason.code) + severity_val = _map_severity(reason.severity) + details_copy = {k: reason.details[k] for k in sorted(reason.details)} if reason.details else {} + return PublicGovernanceReasonV1( + code=code_val, + severity=severity_val, + message=reason.message, + path=reason.path, + details=details_copy, + ) + + +def _project_change_evidence(evidence: GovernanceChangeEvidence) -> PublicGovernanceChangeEvidenceV1: + """Project internal GovernanceChangeEvidence to PublicGovernanceChangeEvidenceV1.""" + source_val = _map_evidence_source(evidence.source) + return PublicGovernanceChangeEvidenceV1( + source=source_val, + code=evidence.code, + ) + + +def _project_change(change: GovernanceChange) -> PublicGovernanceChangeV1: + """Project internal GovernanceChange to PublicGovernanceChangeV1 with deterministic sorting.""" + change_type_val = _map_change_type(change.change_type) + entity_type_val = _map_entity_type(change.entity_type) + domain_val = _map_domain(change.domain) + projected_evidence = tuple( + sorted( + (_project_change_evidence(ev) for ev in change.evidence), + key=lambda e: (e.source, e.code), + ) + ) + reason_codes_val = tuple( + sorted({ + rc.value if isinstance(rc, GovernanceReasonCode) else str(rc) + for rc in change.reason_codes + }) + ) + return PublicGovernanceChangeV1( + change_type=change_type_val, + entity_type=entity_type_val, + identity=tuple(change.identity), + path=change.path, + field=change.field, + before=change.before, + after=change.after, + domain=domain_val, + breaking=change.breaking, + reason_codes=reason_codes_val, + evidence=projected_evidence, + ) + + +def to_public_governance_decision(decision: GovernanceDecision) -> PublicGovernanceDecisionV1: + """Project an internal GovernanceDecision domain model into a PublicGovernanceDecisionV1.""" + if not isinstance(decision, GovernanceDecision): + raise TypeError( + 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 + projected_reasons = tuple(_project_reason(r) for r in decision.reasons) + + # 3. 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) + policy_violations = tuple(_project_reason(r) for r in decision.policy.violations) + policy = PublicPolicyOutcomeV1( + valid=decision.policy.valid, + id_violation=decision.policy.id_violation, + version_violation=decision.policy.version_violation, + retired_violation=decision.policy.retired_violation, + violations=policy_violations, + ) + + # 5. Project evidence + evidence = PublicChangeEvidenceV1( + has_changes=decision.evidence.has_changes, + merge_conflicts_count=decision.evidence.merge_conflicts_count, + ) + + # 6. Project canonical changes + projected_changes = tuple(_project_change(c) for c in decision.changes) + + # 7. 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) + for c in projected_changes: + all_reason_codes.update(c.reason_codes) + + decision_val = _map_decision(decision.decision) + bump_val = _map_required_bump(decision.required_version_bump) + + return PublicGovernanceDecisionV1( + schema_version="1", + 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)), + reasons=projected_reasons, + validation=validation, + policy=policy, + evidence=evidence, + changes=projected_changes, + ) + + +def serialize_public_governance_decision( + decision: GovernanceDecision | PublicGovernanceDecisionV1, + *, + indent: int | None = None, +) -> str: + """Serialize a governance decision into stable canonical JSON with sorted keys.""" + if isinstance(decision, GovernanceDecision): + public_decision = to_public_governance_decision(decision) + elif isinstance(decision, PublicGovernanceDecisionV1): + public_decision = decision + else: + raise TypeError( + f"Expected GovernanceDecision or PublicGovernanceDecisionV1, got {type(decision).__name__}" + ) + + dumped = public_decision.model_dump(mode="json", by_alias=True) + return json.dumps(dumped, indent=indent, sort_keys=True, ensure_ascii=False) diff --git a/tests/fixtures/governance_decisions/allow_clean_decision.json b/tests/fixtures/governance_decisions/allow_clean_decision.json new file mode 100644 index 0000000..9145b0b --- /dev/null +++ b/tests/fixtures/governance_decisions/allow_clean_decision.json @@ -0,0 +1,39 @@ +{ + "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 new file mode 100644 index 0000000..4f2b4cb --- /dev/null +++ b/tests/fixtures/governance_decisions/block_retired_decision.json @@ -0,0 +1,72 @@ +{ + "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 new file mode 100644 index 0000000..41f06a3 --- /dev/null +++ b/tests/fixtures/governance_decisions/block_validation_decision.json @@ -0,0 +1,55 @@ +{ + "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 new file mode 100644 index 0000000..bda1b12 --- /dev/null +++ b/tests/fixtures/governance_decisions/breaking_review_decision.json @@ -0,0 +1,74 @@ +{ + "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 new file mode 100644 index 0000000..6d04092 --- /dev/null +++ b/tests/fixtures/governance_decisions/review_deprecate_decision.json @@ -0,0 +1,56 @@ +{ + "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/test_public_governance_decision.py b/tests/test_public_governance_decision.py new file mode 100644 index 0000000..4d74e83 --- /dev/null +++ b/tests/test_public_governance_decision.py @@ -0,0 +1,485 @@ +"""Tests for PublicGovernanceDecisionV1 schema, projection, serialization, and golden fixtures.""" + +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 ( + CustomProperty, + OpenDataContractStandard, + SchemaObject, + SchemaProperty, +) + +from semapact.change_context import ChangeContext +from semapact.governance import ( + PublicChangeContextV1, + PublicChangeEvidenceV1, + PublicGovernanceChangeEvidenceV1, + PublicGovernanceChangeV1, + PublicGovernanceDecisionV1, + PublicGovernanceReasonV1, + PublicPolicyOutcomeV1, + PublicValidationOutcomeV1, + evaluate_governance_decision, + serialize_public_governance_decision, + to_public_governance_decision, +) + + +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 [] + + +def _make_contract( + contract_id: str = "my-data-contract", + version: str = "1.0.0", + status: str = "active", + properties: list[SchemaProperty] | None = None, +) -> OpenDataContractStandard: + if properties is None: + properties = [ + SchemaProperty( + name="id", + logicalType="string", + physicalType="varchar(255)", + required=True, + ), + SchemaProperty( + name="amount", + logicalType="number", + physicalType="decimal(10,2)", + required=False, + ), + ] + return OpenDataContractStandard( + apiVersion="v3.1.0", + kind="DataContract", + id=contract_id, + name=contract_id, + version=version, + status=status, + schema=[ + SchemaObject( + name="orders", + physicalName="orders", + properties=properties, + ) + ], + ) + + +def test_public_decision_allow_scenario(): + """Identical active contracts produce ALLOW decision with clean public projection.""" + base = _make_contract() + candidate = _make_contract() + + decision = evaluate_governance_decision(base, candidate, context=TEST_CONTEXT) + public_dec = to_public_governance_decision(decision) + + assert isinstance(public_dec, PublicGovernanceDecisionV1) + assert public_dec.schema_version == "1" + assert public_dec.decision == "ALLOW" + 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 + assert public_dec.policy.version_violation is False + assert public_dec.policy.retired_violation is False + assert public_dec.evidence.has_changes is False + assert public_dec.evidence.merge_conflicts_count == 0 + assert len(public_dec.reasons) == 1 + assert public_dec.reasons[0].code == "CHANGE_ASSESSMENT" + assert public_dec.reasons[0].severity == "INFO" + assert len(public_dec.changes) == 0 + assert public_dec.reason_codes == ("CHANGE_ASSESSMENT",) + + # Verify camelCase serialization + dumped = public_dec.to_canonical_dict() + assert dumped["schemaVersion"] == "1" + assert dumped["decisionId"] == decision.decision_id + assert dumped["contractId"] == "my-data-contract" + assert dumped["context"] == {"effectiveDate": "2026-01-01"} + assert dumped["requiredVersionBump"] == "none" + assert dumped["evidence"] == {"hasChanges": False, "mergeConflictsCount": 0} + assert dumped["changes"] == [] + assert dumped["reasonCodes"] == ["CHANGE_ASSESSMENT"] + + +def test_public_decision_review_scenario(): + """Deprecating a property in an active contract produces a valid REVIEW public decision.""" + base = _make_contract() + cand = _make_contract() + _get_schemas(cand)[0].properties[1].customProperties = [ + CustomProperty(property="lifecycleStatus", value="deprecated") + ] + + decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + public_dec = to_public_governance_decision(decision) + + assert public_dec.schema_version == "1" + assert public_dec.decision == "REVIEW" + assert public_dec.breaking is False + assert public_dec.required_version_bump == "minor" + assert public_dec.evidence.has_changes is True + assert len(public_dec.changes) > 0 + + # Ensure reasonCodes contains sorted unique code identifiers + assert "CHANGE_ASSESSMENT" in public_dec.reason_codes + + # Ensure canonical change contains camelCase and proper fields + dumped = public_dec.to_canonical_dict() + first_change = dumped["changes"][0] + assert "changeType" in first_change + assert "entityType" in first_change + assert "domain" in first_change + assert "reasonCodes" in first_change + assert "identity" in first_change + + +def test_public_decision_breaking_review_scenario(): + """Decimal precision reduction in active contract produces REVIEW decision with major bump and breaking=True.""" + base = _make_contract() + cand = _make_contract() + _get_schemas(cand)[0].properties[1].physicalType = "decimal(8,2)" + + decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + public_dec = to_public_governance_decision(decision) + + assert public_dec.schema_version == "1" + assert public_dec.decision == "REVIEW" + assert public_dec.breaking is True + assert public_dec.required_version_bump == "major" + assert public_dec.reason_codes == ("CHANGE_ASSESSMENT", "DECIMAL_PRECISION_REDUCED") + + dumped = public_dec.to_canonical_dict() + assert dumped["decision"] == "REVIEW" + assert dumped["breaking"] is True + assert dumped["requiredVersionBump"] == "major" + assert any(r["code"] == "DECIMAL_PRECISION_REDUCED" for r in dumped["reasons"]) + + +def test_public_decision_block_retired_scenario(): + """Modifying retired contract produces BLOCK decision with RETIRED_CONTRACT_MODIFIED.""" + base = _make_contract(status="retired") + cand = _make_contract(status="retired") + _get_schemas(cand)[0].properties[1].description = "New description" + + decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + public_dec = to_public_governance_decision(decision) + + assert public_dec.decision == "BLOCK" + assert public_dec.policy.valid is False + assert public_dec.policy.retired_violation is True + assert "RETIRED_CONTRACT_MODIFIED" in public_dec.reason_codes + + dumped = public_dec.to_canonical_dict() + assert dumped["policy"]["retiredViolation"] is True + + +def test_public_decision_block_validation_scenario(): + """Invalid candidate schema property causes BLOCK decision with VALIDATION_FAILED.""" + base = _make_contract() + cand = _make_contract() + _get_schemas(cand)[0].properties.append( + SchemaProperty(name="", logicalType="string", physicalType="", required=True) + ) + + decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + public_dec = to_public_governance_decision(decision) + + assert public_dec.decision == "BLOCK" + assert public_dec.validation.valid is False + assert "VALIDATION_FAILED" in public_dec.reason_codes + + dumped = public_dec.to_canonical_dict() + assert dumped["decision"] == "BLOCK" + assert dumped["validation"]["valid"] is False + + +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", + message="A test warning", + path="schema.orders", + details={"key": "val"}, + ) + validation = PublicValidationOutcomeV1(valid=True, issues=(reason,)) + policy = PublicPolicyOutcomeV1(valid=True, violations=()) + evidence = PublicChangeEvidenceV1(has_changes=True, merge_conflicts_count=1) + ev_source = PublicGovernanceChangeEvidenceV1(source="MERGE_CONFLICT", code="conflict") + change = PublicGovernanceChangeV1( + change_type="MODIFY", + entity_type="PROPERTY", + identity=("orders", "amount"), + path="schema[orders].properties[amount]", + field="physicalType", + before="decimal(10,2)", + after="decimal(8,2)", + domain="STRUCTURE", + breaking=True, + reason_codes=("DECIMAL_PRECISION_REDUCED",), + evidence=(ev_source,), + ) + + decision = PublicGovernanceDecisionV1( + schema_version="1", + 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",), + reasons=(reason,), + validation=validation, + policy=policy, + evidence=evidence, + changes=(change,), + ) + + assert decision.context.effective_date == "2026-08-28" + assert decision.changes[0].evidence[0].source == "MERGE_CONFLICT" + assert decision.changes[0].field == "physicalType" + assert decision.validation.issues[0].details == {"key": "val"} + + +def test_public_literals_strict_validation(): + """Verify that invalid strings for protocol literals are rejected at validation.""" + with pytest.raises(PydanticValidationError): + PublicGovernanceDecisionV1.model_validate({ + "schemaVersion": "1", + "decisionId": "test", + "decision": "BANANA", # Invalid decision + "contractId": "orders", + "context": {"effectiveDate": "2026-01-01"}, + "breaking": False, + "requiredVersionBump": "none", + "reasonCodes": [], + "reasons": [], + "validation": {"valid": True, "issues": []}, + "policy": {"valid": True, "idViolation": False, "versionViolation": False, "retiredViolation": False, "violations": []}, + "evidence": {"hasChanges": False, "mergeConflictsCount": 0}, + "changes": [], + }) + + with pytest.raises(PydanticValidationError): + PublicGovernanceDecisionV1.model_validate({ + "schemaVersion": "1", + "decisionId": "test", + "decision": "ALLOW", + "contractId": "orders", + "context": {"effectiveDate": "2026-01-01"}, + "breaking": False, + "requiredVersionBump": "huge", # Invalid bump + "reasonCodes": [], + "reasons": [], + "validation": {"valid": True, "issues": []}, + "policy": {"valid": True, "idViolation": False, "versionViolation": False, "retiredViolation": False, "violations": []}, + "evidence": {"hasChanges": False, "mergeConflictsCount": 0}, + "changes": [], + }) + + # Reject speculative "SERVER" entity type not yet in canonical v1 protocol + with pytest.raises(PydanticValidationError): + PublicGovernanceChangeV1( + change_type="MODIFY", + entity_type="SERVER", # type: ignore[arg-type] + identity=("orders",), + path="servers[0]", + domain="STRUCTURE", + ) + + # Reject "patch" for requiredVersionBump as lifecycle policy only specifies none/minor/major + with pytest.raises(PydanticValidationError): + PublicGovernanceDecisionV1.model_validate({ + "schemaVersion": "1", + "decisionId": "test", + "decision": "ALLOW", + "contractId": "orders", + "context": {"effectiveDate": "2026-01-01"}, + "breaking": False, + "requiredVersionBump": "patch", # Invalid bump for v1 + "reasonCodes": [], + "reasons": [], + "validation": {"valid": True, "issues": []}, + "policy": {"valid": True, "idViolation": False, "versionViolation": False, "retiredViolation": False, "violations": []}, + "evidence": {"hasChanges": False, "mergeConflictsCount": 0}, + "changes": [], + }) + + +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) + public_dec = to_public_governance_decision(decision) + + # Immutability + with pytest.raises((PydanticValidationError, TypeError)): + public_dec.decision = "BLOCK" # type: ignore + + # Extra forbid + with pytest.raises(PydanticValidationError): + PublicGovernanceDecisionV1.model_validate({ + "schemaVersion": "1", + "decisionId": "test", + "decision": "ALLOW", + "contractId": "orders", + "context": {"effectiveDate": "2026-01-01"}, + "breaking": False, + "requiredVersionBump": "none", + "reasonCodes": [], + "reasons": [], + "validation": {"valid": True, "issues": []}, + "policy": {"valid": True, "idViolation": False, "versionViolation": False, "retiredViolation": False, "violations": []}, + "evidence": {"hasChanges": False, "mergeConflictsCount": 0}, + "changes": [], + "unknown_extra_field": 123, + }) + + +def test_public_decision_byte_level_determinism(): + """Semantically equivalent states with differing incidental ordering produce byte-for-byte identical canonical JSON.""" + # Decision 1 + reason1 = PublicGovernanceReasonV1( + code="RULE_VIOLATION", + severity="WARNING", + message="Message", + details={"zebra": 1, "apple": 2, "mango": 3}, + ) + change1 = PublicGovernanceChangeV1( + change_type="MODIFY", + entity_type="PROPERTY", + identity=("orders", "amount"), + path="schema[orders].properties[amount]", + domain="STRUCTURE", + breaking=True, + reason_codes=("Z_CODE", "A_CODE"), + ) + dec1 = PublicGovernanceDecisionV1( + schema_version="1", + 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"), + reasons=(reason1,), + validation=PublicValidationOutcomeV1(valid=True), + policy=PublicPolicyOutcomeV1(valid=True), + evidence=PublicChangeEvidenceV1(has_changes=True), + changes=(change1,), + ) + + # Decision 2 with different key insertion order in details + reason2 = PublicGovernanceReasonV1( + code="RULE_VIOLATION", + severity="WARNING", + message="Message", + details={"mango": 3, "apple": 2, "zebra": 1}, + ) + dec2 = PublicGovernanceDecisionV1( + schema_version="1", + 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"), + reasons=(reason2,), + validation=PublicValidationOutcomeV1(valid=True), + policy=PublicPolicyOutcomeV1(valid=True), + evidence=PublicChangeEvidenceV1(has_changes=True), + changes=(change1,), + ) + + json1 = serialize_public_governance_decision(dec1, indent=2) + json2 = serialize_public_governance_decision(dec2, indent=2) + + assert json1 == json2 + assert json1.encode("utf-8") == json2.encode("utf-8") + + +def test_public_decision_roundtrip_deserialization(): + """PublicGovernanceDecisionV1 can deserialize from camelCase JSON and equals original.""" + base = _make_contract() + cand = _make_contract() + _get_schemas(cand)[0].properties[1].physicalType = "decimal(8,2)" + + decision = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + public_dec = to_public_governance_decision(decision) + + json_str = serialize_public_governance_decision(public_dec) + restored = PublicGovernanceDecisionV1.model_validate_json(json_str) + + assert restored == public_dec + assert restored.decision_id == public_dec.decision_id + 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