From bb9a971dbe1908764032ee28494d4585e28fead3 Mon Sep 17 00:00:00 2001 From: ElliotSun Date: Fri, 28 Aug 2026 22:05:29 +1000 Subject: [PATCH 1/6] feat(governance): expose stable machine-readable GovernanceDecision contract (#29) --- semapact/governance/__init__.py | 24 ++ semapact/governance/models.py | 7 + semapact/governance/public.py | 296 +++++++++++++ .../allow_clean_decision.json | 39 ++ .../block_retired_decision.json | 72 ++++ .../block_validation_decision.json | 55 +++ .../breaking_review_decision.json | 74 ++++ .../review_deprecate_decision.json | 56 +++ tests/test_public_governance_decision.py | 391 ++++++++++++++++++ 9 files changed, 1014 insertions(+) create mode 100644 semapact/governance/public.py create mode 100644 tests/fixtures/governance_decisions/allow_clean_decision.json create mode 100644 tests/fixtures/governance_decisions/block_retired_decision.json create mode 100644 tests/fixtures/governance_decisions/block_validation_decision.json create mode 100644 tests/fixtures/governance_decisions/breaking_review_decision.json create mode 100644 tests/fixtures/governance_decisions/review_deprecate_decision.json create mode 100644 tests/test_public_governance_decision.py diff --git a/semapact/governance/__init__.py b/semapact/governance/__init__.py index 2f023bd..ae386f7 100644 --- a/semapact/governance/__init__.py +++ b/semapact/governance/__init__.py @@ -23,6 +23,19 @@ enforce_governance_gate, evaluate_governance_gate, ) +from semapact.governance.public import ( + PublicChangeContextV1, + PublicChangeEvidenceV1, + PublicGovernanceChangeEvidenceV1, + PublicGovernanceChangeV1, + PublicGovernanceDecisionV1, + PublicGovernanceModel, + PublicGovernanceReasonV1, + PublicPolicyOutcomeV1, + PublicValidationOutcomeV1, + serialize_public_governance_decision, + to_public_governance_decision, +) __all__ = [ "ChangeContext", @@ -42,4 +55,15 @@ "evaluate_governance_decision", "evaluate_governance_gate", "enforce_governance_gate", + "PublicGovernanceModel", + "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..7d0d6b8 100644 --- a/semapact/governance/models.py +++ b/semapact/governance/models.py @@ -110,3 +110,10 @@ 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 + + def to_public(self) -> Any: + """Project this domain model into the public versioned representation.""" + from semapact.governance.public import to_public_governance_decision + + return to_public_governance_decision(self) + diff --git a/semapact/governance/public.py b/semapact/governance/public.py new file mode 100644 index 0000000..ec3a291 --- /dev/null +++ b/semapact/governance/public.py @@ -0,0 +1,296 @@ +"""Stable, versioned public contract for SemaPact governance decisions (v1).""" + +from __future__ import annotations + +import json +from typing import Any, Literal +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +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, +) + + +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(alias="effectiveDate") + + +class PublicGovernanceReasonV1(PublicGovernanceModel): + """Public structured reason for a governance outcome or violation.""" + + code: str + severity: str + 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, alias="idViolation") + version_violation: bool = Field(default=False, alias="versionViolation") + retired_violation: bool = Field(default=False, alias="retiredViolation") + violations: tuple[PublicGovernanceReasonV1, ...] = () + + +class PublicChangeEvidenceV1(PublicGovernanceModel): + """Public summarized evidence for contract mutations.""" + + has_changes: bool = Field(default=False, alias="hasChanges") + merge_conflicts_count: int = Field(default=0, alias="mergeConflictsCount") + + +class PublicGovernanceChangeEvidenceV1(PublicGovernanceModel): + """Public evidence source supporting a semantic change.""" + + source: str + description: str + + +class PublicGovernanceChangeV1(PublicGovernanceModel): + """Public canonical representation of a single semantic contract change.""" + + change_type: str = Field(alias="changeType") + entity_type: str = Field(alias="entityType") + identity: tuple[str, ...] + path: str + field: str | None = None + before: JsonValue | None = None + after: JsonValue | None = None + domain: str + breaking: bool = False + reason_codes: tuple[str, ...] = Field(default=(), alias="reasonCodes") + evidence: tuple[PublicGovernanceChangeEvidenceV1, ...] = () + + +class PublicGovernanceDecisionV1(PublicGovernanceModel): + """Authoritative, versioned public contract for a SemaPact governance decision.""" + + schema_version: Literal["1"] = Field(default="1", alias="schemaVersion") + decision_id: str = Field(alias="decisionId") + decision: str + contract_id: str = Field(alias="contractId") + context: PublicChangeContextV1 + breaking: bool + required_version_bump: str = Field(alias="requiredVersionBump") + reason_codes: tuple[str, ...] = Field(default=(), 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.""" + 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) + + +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 = ( + reason.severity.value + if isinstance(reason.severity, GovernanceSeverity) + else str(reason.severity) + ) + details_copy = {k: v for k, v in sorted(reason.details.items())} 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 = ( + evidence.source.value + if isinstance(evidence.source, GovernanceChangeEvidenceSource) + else str(evidence.source) + ) + return PublicGovernanceChangeEvidenceV1( + source=source_val, + description=evidence.description, + ) + + +def _project_change(change: GovernanceChange) -> PublicGovernanceChangeV1: + """Project internal GovernanceChange to PublicGovernanceChangeV1.""" + change_type_val = ( + change.change_type.value + if isinstance(change.change_type, GovernanceChangeType) + else str(change.change_type) + ) + entity_type_val = ( + change.entity_type.value + if isinstance(change.entity_type, GovernanceEntityType) + else str(change.entity_type) + ) + domain_val = ( + change.domain.value + if isinstance(change.domain, GovernanceChangeDomain) + else str(change.domain) + ) + projected_evidence = tuple(_project_change_evidence(ev) for ev in change.evidence) + reason_codes_val = tuple( + 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 order + ordered_reason_codes: list[str] = [] + seen_codes: set[str] = set() + + for r in projected_reasons: + if r.code not in seen_codes: + seen_codes.add(r.code) + ordered_reason_codes.append(r.code) + + for c in projected_changes: + for rc in c.reason_codes: + if rc not in seen_codes: + seen_codes.add(rc) + ordered_reason_codes.append(rc) + + decision_val = ( + decision.decision.value + if isinstance(decision.decision, DecisionResult) + else str(decision.decision) + ) + + 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=str(decision.required_version_bump), + reason_codes=tuple(ordered_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.""" + 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, 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..87ef2b9 --- /dev/null +++ b/tests/fixtures/governance_decisions/allow_clean_decision.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": "1", + "decisionId": "0480ed51-8b7f-57aa-93f2-b09c135004c1", + "decision": "ALLOW", + "contractId": "my-data-contract", + "context": { + "effectiveDate": "2026-01-01" + }, + "breaking": false, + "requiredVersionBump": "none", + "reasonCodes": [ + "CHANGE_ASSESSMENT" + ], + "reasons": [ + { + "code": "CHANGE_ASSESSMENT", + "severity": "INFO", + "message": "No contract changes detected", + "path": null, + "details": {} + } + ], + "validation": { + "valid": true, + "issues": [] + }, + "policy": { + "valid": true, + "idViolation": false, + "versionViolation": false, + "retiredViolation": false, + "violations": [] + }, + "evidence": { + "hasChanges": false, + "mergeConflictsCount": 0 + }, + "changes": [] +} \ No newline at end of file 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..d607a35 --- /dev/null +++ b/tests/fixtures/governance_decisions/block_retired_decision.json @@ -0,0 +1,72 @@ +{ + "schemaVersion": "1", + "decisionId": "e8296105-5b21-5c7b-b5f1-99004a632224", + "decision": "BLOCK", + "contractId": "my-data-contract", + "context": { + "effectiveDate": "2026-01-01" + }, + "breaking": false, + "requiredVersionBump": "none", + "reasonCodes": [ + "CHANGE_ASSESSMENT", + "RETIRED_CONTRACT_MODIFIED" + ], + "reasons": [ + { + "code": "CHANGE_ASSESSMENT", + "severity": "INFO", + "message": "Only descriptive metadata changed; no required version bump", + "path": null, + "details": {} + }, + { + "code": "RETIRED_CONTRACT_MODIFIED", + "severity": "ERROR", + "message": "Cannot modify a retired contract.", + "path": "status", + "details": {} + } + ], + "validation": { + "valid": true, + "issues": [] + }, + "policy": { + "valid": false, + "idViolation": false, + "versionViolation": false, + "retiredViolation": true, + "violations": [ + { + "code": "RETIRED_CONTRACT_MODIFIED", + "severity": "ERROR", + "message": "Cannot modify a retired contract.", + "path": "status", + "details": {} + } + ] + }, + "evidence": { + "hasChanges": true, + "mergeConflictsCount": 0 + }, + "changes": [ + { + "changeType": "MODIFY", + "entityType": "PROPERTY", + "identity": [ + "orders", + "amount" + ], + "path": "schema[orders].properties[amount].description", + "field": "description", + "before": null, + "after": "Retired update", + "domain": "METADATA", + "breaking": false, + "reasonCodes": [], + "evidence": [] + } + ] +} \ No newline at end of file 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..a7438fd --- /dev/null +++ b/tests/fixtures/governance_decisions/block_validation_decision.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": "1", + "decisionId": "3de328dd-f6f1-5bff-b805-c3f1ff2325c4", + "decision": "BLOCK", + "contractId": "my-data-contract", + "context": { + "effectiveDate": "2026-01-01" + }, + "breaking": false, + "requiredVersionBump": "none", + "reasonCodes": [ + "CHANGE_ASSESSMENT", + "VALIDATION_FAILED" + ], + "reasons": [ + { + "code": "CHANGE_ASSESSMENT", + "severity": "INFO", + "message": "No contract changes detected", + "path": null, + "details": {} + }, + { + "code": "VALIDATION_FAILED", + "severity": "ERROR", + "message": "Property name cannot be empty or whitespace-only", + "path": "schema", + "details": {} + } + ], + "validation": { + "valid": false, + "issues": [ + { + "code": "VALIDATION_FAILED", + "severity": "ERROR", + "message": "Property name cannot be empty or whitespace-only", + "path": "schema", + "details": {} + } + ] + }, + "policy": { + "valid": false, + "idViolation": false, + "versionViolation": false, + "retiredViolation": false, + "violations": [] + }, + "evidence": { + "hasChanges": true, + "mergeConflictsCount": 0 + }, + "changes": [] +} \ No newline at end of file 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..4c1c6dc --- /dev/null +++ b/tests/fixtures/governance_decisions/breaking_review_decision.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": "1", + "decisionId": "dc341a61-b19f-5c01-a250-0fda5ea7d061", + "decision": "REVIEW", + "contractId": "my-data-contract", + "context": { + "effectiveDate": "2026-01-01" + }, + "breaking": true, + "requiredVersionBump": "major", + "reasonCodes": [ + "CHANGE_ASSESSMENT", + "DECIMAL_PRECISION_REDUCED" + ], + "reasons": [ + { + "code": "CHANGE_ASSESSMENT", + "severity": "INFO", + "message": "Breaking lifecycle changes require a major version bump", + "path": null, + "details": {} + }, + { + "code": "DECIMAL_PRECISION_REDUCED", + "severity": "WARNING", + "message": "Decimal precision reduced from 'decimal(10,2)' to 'decimal(8,2)'", + "path": "schema[orders].properties[amount].physicalType", + "details": {} + } + ], + "validation": { + "valid": true, + "issues": [] + }, + "policy": { + "valid": false, + "idViolation": false, + "versionViolation": false, + "retiredViolation": false, + "violations": [ + { + "code": "DECIMAL_PRECISION_REDUCED", + "severity": "WARNING", + "message": "Decimal precision reduced from 'decimal(10,2)' to 'decimal(8,2)'", + "path": "schema[orders].properties[amount].physicalType", + "details": {} + } + ] + }, + "evidence": { + "hasChanges": true, + "mergeConflictsCount": 0 + }, + "changes": [ + { + "changeType": "MODIFY", + "entityType": "PROPERTY", + "identity": [ + "orders", + "amount" + ], + "path": "schema[orders].properties[amount].physicalType", + "field": "physicalType", + "before": "decimal(10,2)", + "after": "decimal(8,2)", + "domain": "STRUCTURE", + "breaking": true, + "reasonCodes": [ + "DECIMAL_PRECISION_REDUCED" + ], + "evidence": [] + } + ] +} \ No newline at end of file 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..1982bc3 --- /dev/null +++ b/tests/fixtures/governance_decisions/review_deprecate_decision.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": "1", + "decisionId": "43daa6d0-6925-5b00-ad33-18e871dadfa9", + "decision": "REVIEW", + "contractId": "my-data-contract", + "context": { + "effectiveDate": "2026-01-01" + }, + "breaking": false, + "requiredVersionBump": "minor", + "reasonCodes": [ + "CHANGE_ASSESSMENT" + ], + "reasons": [ + { + "code": "CHANGE_ASSESSMENT", + "severity": "INFO", + "message": "New schema/property deprecations require a minor version bump", + "path": null, + "details": {} + } + ], + "validation": { + "valid": true, + "issues": [] + }, + "policy": { + "valid": true, + "idViolation": false, + "versionViolation": false, + "retiredViolation": false, + "violations": [] + }, + "evidence": { + "hasChanges": true, + "mergeConflictsCount": 0 + }, + "changes": [ + { + "changeType": "DEPRECATE", + "entityType": "PROPERTY", + "identity": [ + "orders", + "amount" + ], + "path": "schema[orders].properties[amount]", + "field": "lifecycleStatus", + "before": null, + "after": "deprecated", + "domain": "LIFECYCLE", + "breaking": false, + "reasonCodes": [], + "evidence": [] + } + ] +} \ No newline at end of file diff --git a/tests/test_public_governance_decision.py b/tests/test_public_governance_decision.py new file mode 100644 index 0000000..478566e --- /dev/null +++ b/tests/test_public_governance_decision.py @@ -0,0 +1,391 @@ +"""Tests for PublicGovernanceDecisionV1 schema, projection, serialization, and golden fixtures.""" + +from __future__ import annotations + +from datetime import date +import json +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 stable 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 "DECIMAL_PRECISION_REDUCED" in public_dec.reason_codes + assert "CHANGE_ASSESSMENT" in public_dec.reason_codes + + 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", description="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_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(): + """Identical decisions produce byte-for-byte identical canonical JSON.""" + base = _make_contract() + cand = _make_contract() + _get_schemas(cand)[0].properties[1].physicalType = "decimal(8,2)" + + dec1 = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + dec2 = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + + 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.""" + FIXTURES_DIR.mkdir(parents=True, exist_ok=True) + + # 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) + + allow_fixture_path = FIXTURES_DIR / "allow_clean_decision.json" + allow_fixture_path.write_text(allow_json_str, encoding="utf-8") + expected_allow = allow_fixture_path.read_text(encoding="utf-8").strip() + assert json.loads(allow_json_str) == json.loads(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) + + breaking_fixture_path = FIXTURES_DIR / "breaking_review_decision.json" + breaking_fixture_path.write_text(breaking_json_str, encoding="utf-8") + expected_breaking = breaking_fixture_path.read_text(encoding="utf-8").strip() + assert json.loads(breaking_json_str) == json.loads(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) + + review_fixture_path = FIXTURES_DIR / "review_deprecate_decision.json" + review_fixture_path.write_text(review_json_str, encoding="utf-8") + expected_review = review_fixture_path.read_text(encoding="utf-8").strip() + assert json.loads(review_json_str) == json.loads(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) + + retired_fixture_path = FIXTURES_DIR / "block_retired_decision.json" + retired_fixture_path.write_text(retired_json_str, encoding="utf-8") + expected_retired = retired_fixture_path.read_text(encoding="utf-8").strip() + assert json.loads(retired_json_str) == json.loads(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) + + invalid_fixture_path = FIXTURES_DIR / "block_validation_decision.json" + invalid_fixture_path.write_text(invalid_json_str, encoding="utf-8") + expected_invalid = invalid_fixture_path.read_text(encoding="utf-8").strip() + assert json.loads(invalid_json_str) == json.loads(expected_invalid) From 2db37442fce6b97be0606208be5b9357ba84a317 Mon Sep 17 00:00:00 2001 From: ElliotSun Date: Fri, 28 Aug 2026 22:09:29 +1000 Subject: [PATCH 2/6] refactor(governance): enhance public models with AliasChoices for mypy compatibility --- semapact/governance/public.py | 84 +++++++++++++++++++----- tests/test_public_governance_decision.py | 2 +- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/semapact/governance/public.py b/semapact/governance/public.py index ec3a291..585ffeb 100644 --- a/semapact/governance/public.py +++ b/semapact/governance/public.py @@ -4,7 +4,7 @@ import json from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, JsonValue +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, JsonValue from semapact.governance.models import ( DecisionResult, @@ -39,7 +39,10 @@ class PublicGovernanceModel(BaseModel): class PublicChangeContextV1(PublicGovernanceModel): """Public representation of contextual evaluation parameters.""" - effective_date: str = Field(alias="effectiveDate") + effective_date: str = Field( + validation_alias=AliasChoices("effective_date", "effectiveDate"), + serialization_alias="effectiveDate", + ) class PublicGovernanceReasonV1(PublicGovernanceModel): @@ -63,31 +66,57 @@ class PublicPolicyOutcomeV1(PublicGovernanceModel): """Public lifecycle policy outcome.""" valid: bool - id_violation: bool = Field(default=False, alias="idViolation") - version_violation: bool = Field(default=False, alias="versionViolation") - retired_violation: bool = Field(default=False, alias="retiredViolation") + 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, alias="hasChanges") - merge_conflicts_count: int = Field(default=0, alias="mergeConflictsCount") + 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: str - description: str + code: str class PublicGovernanceChangeV1(PublicGovernanceModel): """Public canonical representation of a single semantic contract change.""" - change_type: str = Field(alias="changeType") - entity_type: str = Field(alias="entityType") + change_type: str = Field( + validation_alias=AliasChoices("change_type", "changeType"), + serialization_alias="changeType", + ) + entity_type: str = Field( + validation_alias=AliasChoices("entity_type", "entityType"), + serialization_alias="entityType", + ) identity: tuple[str, ...] path: str field: str | None = None @@ -95,21 +124,42 @@ class PublicGovernanceChangeV1(PublicGovernanceModel): after: JsonValue | None = None domain: str breaking: bool = False - reason_codes: tuple[str, ...] = Field(default=(), alias="reasonCodes") + 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", alias="schemaVersion") - decision_id: str = Field(alias="decisionId") + 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: str - contract_id: str = Field(alias="contractId") + contract_id: str = Field( + validation_alias=AliasChoices("contract_id", "contractId"), + serialization_alias="contractId", + ) context: PublicChangeContextV1 breaking: bool - required_version_bump: str = Field(alias="requiredVersionBump") - reason_codes: tuple[str, ...] = Field(default=(), alias="reasonCodes") + required_version_bump: str = 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 @@ -157,7 +207,7 @@ def _project_change_evidence(evidence: GovernanceChangeEvidence) -> PublicGovern ) return PublicGovernanceChangeEvidenceV1( source=source_val, - description=evidence.description, + code=evidence.code, ) diff --git a/tests/test_public_governance_decision.py b/tests/test_public_governance_decision.py index 478566e..31f5250 100644 --- a/tests/test_public_governance_decision.py +++ b/tests/test_public_governance_decision.py @@ -221,7 +221,7 @@ def test_public_models_direct_instantiation_and_helpers(): 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", description="conflict") + ev_source = PublicGovernanceChangeEvidenceV1(source="MERGE_CONFLICT", code="conflict") change = PublicGovernanceChangeV1( change_type="MODIFY", entity_type="PROPERTY", From 48f2cfa1b33dc304752d9e0dd00c1aa56a74e250 Mon Sep 17 00:00:00 2001 From: ElliotSun Date: Fri, 28 Aug 2026 22:17:42 +1000 Subject: [PATCH 3/6] fix(governance): harden public contract with Literals, sorted keys, and strict read-only golden tests --- semapact/governance/__init__.py | 14 ++ semapact/governance/models.py | 6 - semapact/governance/public.py | 105 ++++++++---- .../allow_clean_decision.json | 48 +++--- .../block_retired_decision.json | 102 ++++++------ .../block_validation_decision.json | 56 +++---- .../breaking_review_decision.json | 106 ++++++------ .../review_deprecate_decision.json | 82 +++++----- tests/test_public_governance_decision.py | 152 +++++++++++++----- 9 files changed, 390 insertions(+), 281 deletions(-) diff --git a/semapact/governance/__init__.py b/semapact/governance/__init__.py index ae386f7..bd9be77 100644 --- a/semapact/governance/__init__.py +++ b/semapact/governance/__init__.py @@ -25,13 +25,20 @@ ) 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, @@ -56,6 +63,13 @@ "evaluate_governance_gate", "enforce_governance_gate", "PublicGovernanceModel", + "PublicDecisionResult", + "PublicRequiredVersionBump", + "PublicSeverity", + "PublicChangeType", + "PublicEntityType", + "PublicChangeDomain", + "PublicEvidenceSource", "PublicChangeContextV1", "PublicGovernanceReasonV1", "PublicValidationOutcomeV1", diff --git a/semapact/governance/models.py b/semapact/governance/models.py index 7d0d6b8..ecf2b30 100644 --- a/semapact/governance/models.py +++ b/semapact/governance/models.py @@ -111,9 +111,3 @@ def _validate_allow_invariants(self) -> GovernanceDecision: ) return self - def to_public(self) -> Any: - """Project this domain model into the public versioned representation.""" - from semapact.governance.public import to_public_governance_decision - - return to_public_governance_decision(self) - diff --git a/semapact/governance/public.py b/semapact/governance/public.py index 585ffeb..3f8349f 100644 --- a/semapact/governance/public.py +++ b/semapact/governance/public.py @@ -22,6 +22,38 @@ ) +# ============================================================================== +# Public Protocol Vocabulary Literals +# ============================================================================== + +PublicDecisionResult = Literal["ALLOW", "REVIEW", "BLOCK"] +PublicRequiredVersionBump = Literal["none", "patch", "minor", "major"] +PublicSeverity = Literal["ERROR", "WARNING", "INFO"] +PublicChangeType = Literal["ADD", "REMOVE", "MODIFY", "DEPRECATE"] +PublicEntityType = Literal[ + "CONTRACT", + "SCHEMA", + "PROPERTY", + "RELATIONSHIP", + "QUALITY", + "SERVER", +] +PublicChangeDomain = Literal[ + "IDENTITY", + "VERSION", + "LIFECYCLE", + "STRUCTURE", + "RELATIONSHIP", + "QUALITY", + "METADATA", +] +PublicEvidenceSource = Literal["MERGE_CONFLICT"] + + +# ============================================================================== +# Public Models +# ============================================================================== + class PublicGovernanceModel(BaseModel): """Shared base model for all public governance models. @@ -49,7 +81,7 @@ class PublicGovernanceReasonV1(PublicGovernanceModel): """Public structured reason for a governance outcome or violation.""" code: str - severity: str + severity: PublicSeverity message: str path: str | None = None details: dict[str, Any] = Field(default_factory=dict) @@ -102,18 +134,18 @@ class PublicChangeEvidenceV1(PublicGovernanceModel): class PublicGovernanceChangeEvidenceV1(PublicGovernanceModel): """Public evidence source supporting a semantic change.""" - source: str + source: PublicEvidenceSource code: str class PublicGovernanceChangeV1(PublicGovernanceModel): """Public canonical representation of a single semantic contract change.""" - change_type: str = Field( + change_type: PublicChangeType = Field( validation_alias=AliasChoices("change_type", "changeType"), serialization_alias="changeType", ) - entity_type: str = Field( + entity_type: PublicEntityType = Field( validation_alias=AliasChoices("entity_type", "entityType"), serialization_alias="entityType", ) @@ -122,7 +154,7 @@ class PublicGovernanceChangeV1(PublicGovernanceModel): field: str | None = None before: JsonValue | None = None after: JsonValue | None = None - domain: str + domain: PublicChangeDomain breaking: bool = False reason_codes: tuple[str, ...] = Field( default=(), @@ -144,14 +176,14 @@ class PublicGovernanceDecisionV1(PublicGovernanceModel): validation_alias=AliasChoices("decision_id", "decisionId"), serialization_alias="decisionId", ) - decision: str + decision: PublicDecisionResult contract_id: str = Field( validation_alias=AliasChoices("contract_id", "contractId"), serialization_alias="contractId", ) context: PublicChangeContextV1 breaking: bool - required_version_bump: str = Field( + required_version_bump: PublicRequiredVersionBump = Field( validation_alias=AliasChoices("required_version_bump", "requiredVersionBump"), serialization_alias="requiredVersionBump", ) @@ -172,7 +204,7 @@ def from_domain(cls, decision: GovernanceDecision) -> PublicGovernanceDecisionV1 return to_public_governance_decision(decision) def to_canonical_json(self, *, indent: int | None = None) -> str: - """Serialize to deterministic canonical JSON.""" + """Serialize to deterministic canonical JSON with sorted keys.""" return serialize_public_governance_decision(self, indent=indent) def to_canonical_dict(self) -> dict[str, Any]: @@ -180,6 +212,10 @@ def to_canonical_dict(self) -> dict[str, Any]: 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) @@ -188,10 +224,10 @@ def _project_reason(reason: GovernanceReason) -> PublicGovernanceReasonV1: if isinstance(reason.severity, GovernanceSeverity) else str(reason.severity) ) - details_copy = {k: v for k, v in sorted(reason.details.items())} if reason.details else {} + details_copy = {k: reason.details[k] for k in sorted(reason.details)} if reason.details else {} return PublicGovernanceReasonV1( code=code_val, - severity=severity_val, + severity=severity_val, # type: ignore[arg-type] message=reason.message, path=reason.path, details=details_copy, @@ -206,13 +242,13 @@ def _project_change_evidence(evidence: GovernanceChangeEvidence) -> PublicGovern else str(evidence.source) ) return PublicGovernanceChangeEvidenceV1( - source=source_val, + source=source_val, # type: ignore[arg-type] code=evidence.code, ) def _project_change(change: GovernanceChange) -> PublicGovernanceChangeV1: - """Project internal GovernanceChange to PublicGovernanceChangeV1.""" + """Project internal GovernanceChange to PublicGovernanceChangeV1 with deterministic sorting.""" change_type_val = ( change.change_type.value if isinstance(change.change_type, GovernanceChangeType) @@ -228,20 +264,27 @@ def _project_change(change: GovernanceChange) -> PublicGovernanceChangeV1: if isinstance(change.domain, GovernanceChangeDomain) else str(change.domain) ) - projected_evidence = tuple(_project_change_evidence(ev) for ev in change.evidence) + projected_evidence = tuple( + sorted( + (_project_change_evidence(ev) for ev in change.evidence), + key=lambda e: (e.source, e.code), + ) + ) reason_codes_val = tuple( - rc.value if isinstance(rc, GovernanceReasonCode) else str(rc) - for rc in change.reason_codes + 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, + change_type=change_type_val, # type: ignore[arg-type] + entity_type=entity_type_val, # type: ignore[arg-type] identity=tuple(change.identity), path=change.path, field=change.field, before=change.before, after=change.after, - domain=domain_val, + domain=domain_val, # type: ignore[arg-type] breaking=change.breaking, reason_codes=reason_codes_val, evidence=projected_evidence, @@ -289,20 +332,12 @@ def to_public_governance_decision(decision: GovernanceDecision) -> PublicGoverna # 6. Project canonical changes projected_changes = tuple(_project_change(c) for c in decision.changes) - # 7. Aggregate stable unique reason codes in deterministic order - ordered_reason_codes: list[str] = [] - seen_codes: set[str] = set() - + # 7. Aggregate stable unique reason codes in deterministic alphabetical order + all_reason_codes: set[str] = set() for r in projected_reasons: - if r.code not in seen_codes: - seen_codes.add(r.code) - ordered_reason_codes.append(r.code) - + all_reason_codes.add(r.code) for c in projected_changes: - for rc in c.reason_codes: - if rc not in seen_codes: - seen_codes.add(rc) - ordered_reason_codes.append(rc) + all_reason_codes.update(c.reason_codes) decision_val = ( decision.decision.value @@ -313,12 +348,12 @@ def to_public_governance_decision(decision: GovernanceDecision) -> PublicGoverna return PublicGovernanceDecisionV1( schema_version="1", decision_id=decision.decision_id, - decision=decision_val, + decision=decision_val, # type: ignore[arg-type] contract_id=decision.contract_id, context=context, breaking=decision.breaking, - required_version_bump=str(decision.required_version_bump), - reason_codes=tuple(ordered_reason_codes), + required_version_bump=str(decision.required_version_bump), # type: ignore[arg-type] + reason_codes=tuple(sorted(all_reason_codes)), reasons=projected_reasons, validation=validation, policy=policy, @@ -332,7 +367,7 @@ def serialize_public_governance_decision( *, indent: int | None = None, ) -> str: - """Serialize a governance decision into stable canonical JSON.""" + """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): @@ -343,4 +378,4 @@ def serialize_public_governance_decision( ) dumped = public_decision.model_dump(mode="json", by_alias=True) - return json.dumps(dumped, indent=indent, ensure_ascii=False) + 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 index 87ef2b9..9145b0b 100644 --- a/tests/fixtures/governance_decisions/allow_clean_decision.json +++ b/tests/fixtures/governance_decisions/allow_clean_decision.json @@ -1,39 +1,39 @@ { - "schemaVersion": "1", - "decisionId": "0480ed51-8b7f-57aa-93f2-b09c135004c1", - "decision": "ALLOW", - "contractId": "my-data-contract", + "breaking": false, + "changes": [], "context": { "effectiveDate": "2026-01-01" }, - "breaking": false, - "requiredVersionBump": "none", + "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", - "severity": "INFO", + "details": {}, "message": "No contract changes detected", "path": null, - "details": {} + "severity": "INFO" } ], + "requiredVersionBump": "none", + "schemaVersion": "1", "validation": { - "valid": true, - "issues": [] - }, - "policy": { - "valid": true, - "idViolation": false, - "versionViolation": false, - "retiredViolation": false, - "violations": [] - }, - "evidence": { - "hasChanges": false, - "mergeConflictsCount": 0 - }, - "changes": [] -} \ No newline at end of file + "issues": [], + "valid": true + } +} diff --git a/tests/fixtures/governance_decisions/block_retired_decision.json b/tests/fixtures/governance_decisions/block_retired_decision.json index d607a35..4f2b4cb 100644 --- a/tests/fixtures/governance_decisions/block_retired_decision.json +++ b/tests/fixtures/governance_decisions/block_retired_decision.json @@ -1,13 +1,48 @@ { - "schemaVersion": "1", - "decisionId": "e8296105-5b21-5c7b-b5f1-99004a632224", - "decision": "BLOCK", - "contractId": "my-data-contract", + "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" }, - "breaking": false, - "requiredVersionBump": "none", + "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" @@ -15,58 +50,23 @@ "reasons": [ { "code": "CHANGE_ASSESSMENT", - "severity": "INFO", + "details": {}, "message": "Only descriptive metadata changed; no required version bump", "path": null, - "details": {} + "severity": "INFO" }, { "code": "RETIRED_CONTRACT_MODIFIED", - "severity": "ERROR", + "details": {}, "message": "Cannot modify a retired contract.", "path": "status", - "details": {} + "severity": "ERROR" } ], + "requiredVersionBump": "none", + "schemaVersion": "1", "validation": { - "valid": true, - "issues": [] - }, - "policy": { - "valid": false, - "idViolation": false, - "versionViolation": false, - "retiredViolation": true, - "violations": [ - { - "code": "RETIRED_CONTRACT_MODIFIED", - "severity": "ERROR", - "message": "Cannot modify a retired contract.", - "path": "status", - "details": {} - } - ] - }, - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "changes": [ - { - "changeType": "MODIFY", - "entityType": "PROPERTY", - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount].description", - "field": "description", - "before": null, - "after": "Retired update", - "domain": "METADATA", - "breaking": false, - "reasonCodes": [], - "evidence": [] - } - ] -} \ No newline at end of file + "issues": [], + "valid": true + } +} diff --git a/tests/fixtures/governance_decisions/block_validation_decision.json b/tests/fixtures/governance_decisions/block_validation_decision.json index a7438fd..41f06a3 100644 --- a/tests/fixtures/governance_decisions/block_validation_decision.json +++ b/tests/fixtures/governance_decisions/block_validation_decision.json @@ -1,13 +1,23 @@ { - "schemaVersion": "1", - "decisionId": "3de328dd-f6f1-5bff-b805-c3f1ff2325c4", - "decision": "BLOCK", - "contractId": "my-data-contract", + "breaking": false, + "changes": [], "context": { "effectiveDate": "2026-01-01" }, - "breaking": false, - "requiredVersionBump": "none", + "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" @@ -15,41 +25,31 @@ "reasons": [ { "code": "CHANGE_ASSESSMENT", - "severity": "INFO", + "details": {}, "message": "No contract changes detected", "path": null, - "details": {} + "severity": "INFO" }, { "code": "VALIDATION_FAILED", - "severity": "ERROR", + "details": {}, "message": "Property name cannot be empty or whitespace-only", "path": "schema", - "details": {} + "severity": "ERROR" } ], + "requiredVersionBump": "none", + "schemaVersion": "1", "validation": { - "valid": false, "issues": [ { "code": "VALIDATION_FAILED", - "severity": "ERROR", + "details": {}, "message": "Property name cannot be empty or whitespace-only", "path": "schema", - "details": {} + "severity": "ERROR" } - ] - }, - "policy": { - "valid": false, - "idViolation": false, - "versionViolation": false, - "retiredViolation": false, - "violations": [] - }, - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "changes": [] -} \ No newline at end of file + ], + "valid": false + } +} diff --git a/tests/fixtures/governance_decisions/breaking_review_decision.json b/tests/fixtures/governance_decisions/breaking_review_decision.json index 4c1c6dc..bda1b12 100644 --- a/tests/fixtures/governance_decisions/breaking_review_decision.json +++ b/tests/fixtures/governance_decisions/breaking_review_decision.json @@ -1,74 +1,74 @@ { - "schemaVersion": "1", - "decisionId": "dc341a61-b19f-5c01-a250-0fda5ea7d061", - "decision": "REVIEW", - "contractId": "my-data-contract", - "context": { - "effectiveDate": "2026-01-01" - }, "breaking": true, - "requiredVersionBump": "major", - "reasonCodes": [ - "CHANGE_ASSESSMENT", - "DECIMAL_PRECISION_REDUCED" - ], - "reasons": [ - { - "code": "CHANGE_ASSESSMENT", - "severity": "INFO", - "message": "Breaking lifecycle changes require a major version bump", - "path": null, - "details": {} - }, + "changes": [ { - "code": "DECIMAL_PRECISION_REDUCED", - "severity": "WARNING", - "message": "Decimal precision reduced from 'decimal(10,2)' to 'decimal(8,2)'", + "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", - "details": {} + "reasonCodes": [ + "DECIMAL_PRECISION_REDUCED" + ] } ], - "validation": { - "valid": true, - "issues": [] + "context": { + "effectiveDate": "2026-01-01" + }, + "contractId": "my-data-contract", + "decision": "REVIEW", + "decisionId": "dc341a61-b19f-5c01-a250-0fda5ea7d061", + "evidence": { + "hasChanges": true, + "mergeConflictsCount": 0 }, "policy": { - "valid": false, "idViolation": false, - "versionViolation": false, "retiredViolation": false, + "valid": false, + "versionViolation": false, "violations": [ { "code": "DECIMAL_PRECISION_REDUCED", - "severity": "WARNING", + "details": {}, "message": "Decimal precision reduced from 'decimal(10,2)' to 'decimal(8,2)'", "path": "schema[orders].properties[amount].physicalType", - "details": {} + "severity": "WARNING" } ] }, - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "changes": [ + "reasonCodes": [ + "CHANGE_ASSESSMENT", + "DECIMAL_PRECISION_REDUCED" + ], + "reasons": [ { - "changeType": "MODIFY", - "entityType": "PROPERTY", - "identity": [ - "orders", - "amount" - ], + "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", - "field": "physicalType", - "before": "decimal(10,2)", - "after": "decimal(8,2)", - "domain": "STRUCTURE", - "breaking": true, - "reasonCodes": [ - "DECIMAL_PRECISION_REDUCED" - ], - "evidence": [] + "severity": "WARNING" } - ] -} \ No newline at end of file + ], + "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 index 1982bc3..6d04092 100644 --- a/tests/fixtures/governance_decisions/review_deprecate_decision.json +++ b/tests/fixtures/governance_decisions/review_deprecate_decision.json @@ -1,56 +1,56 @@ { - "schemaVersion": "1", - "decisionId": "43daa6d0-6925-5b00-ad33-18e871dadfa9", - "decision": "REVIEW", - "contractId": "my-data-contract", + "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" }, - "breaking": false, - "requiredVersionBump": "minor", + "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", - "severity": "INFO", + "details": {}, "message": "New schema/property deprecations require a minor version bump", "path": null, - "details": {} + "severity": "INFO" } ], + "requiredVersionBump": "minor", + "schemaVersion": "1", "validation": { - "valid": true, - "issues": [] - }, - "policy": { - "valid": true, - "idViolation": false, - "versionViolation": false, - "retiredViolation": false, - "violations": [] - }, - "evidence": { - "hasChanges": true, - "mergeConflictsCount": 0 - }, - "changes": [ - { - "changeType": "DEPRECATE", - "entityType": "PROPERTY", - "identity": [ - "orders", - "amount" - ], - "path": "schema[orders].properties[amount]", - "field": "lifecycleStatus", - "before": null, - "after": "deprecated", - "domain": "LIFECYCLE", - "breaking": false, - "reasonCodes": [], - "evidence": [] - } - ] -} \ No newline at end of file + "issues": [], + "valid": true + } +} diff --git a/tests/test_public_governance_decision.py b/tests/test_public_governance_decision.py index 31f5250..813aec4 100644 --- a/tests/test_public_governance_decision.py +++ b/tests/test_public_governance_decision.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import date -import json from pathlib import Path import pytest from pydantic import ValidationError as PydanticValidationError @@ -134,7 +133,7 @@ def test_public_decision_review_scenario(): assert public_dec.evidence.has_changes is True assert len(public_dec.changes) > 0 - # Ensure reasonCodes contains stable code identifiers + # Ensure reasonCodes contains sorted unique code identifiers assert "CHANGE_ASSESSMENT" in public_dec.reason_codes # Ensure canonical change contains camelCase and proper fields @@ -160,8 +159,7 @@ def test_public_decision_breaking_review_scenario(): assert public_dec.decision == "REVIEW" assert public_dec.breaking is True assert public_dec.required_version_bump == "major" - assert "DECIMAL_PRECISION_REDUCED" in public_dec.reason_codes - assert "CHANGE_ASSESSMENT" in public_dec.reason_codes + assert public_dec.reason_codes == ("CHANGE_ASSESSMENT", "DECIMAL_PRECISION_REDUCED") dumped = public_dec.to_canonical_dict() assert dumped["decision"] == "REVIEW" @@ -258,6 +256,43 @@ def test_public_models_direct_instantiation_and_helpers(): 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": [], + }) + + def test_public_decision_immutability_and_extra_forbid(): """Public governance models are frozen and reject extra fields.""" base = _make_contract() @@ -289,13 +324,61 @@ def test_public_decision_immutability_and_extra_forbid(): def test_public_decision_byte_level_determinism(): - """Identical decisions produce byte-for-byte identical canonical JSON.""" - base = _make_contract() - cand = _make_contract() - _get_schemas(cand)[0].properties[1].physicalType = "decimal(8,2)" + """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,), + ) - dec1 = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) - dec2 = evaluate_governance_decision(base, cand, context=TEST_CONTEXT) + # 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) @@ -323,31 +406,23 @@ def test_public_decision_roundtrip_deserialization(): def test_golden_fixtures_match(): - """Verify golden JSON fixtures match projected decisions.""" - FIXTURES_DIR.mkdir(parents=True, exist_ok=True) - + """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) - - allow_fixture_path = FIXTURES_DIR / "allow_clean_decision.json" - allow_fixture_path.write_text(allow_json_str, encoding="utf-8") - expected_allow = allow_fixture_path.read_text(encoding="utf-8").strip() - assert json.loads(allow_json_str) == json.loads(expected_allow) + 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) - - breaking_fixture_path = FIXTURES_DIR / "breaking_review_decision.json" - breaking_fixture_path.write_text(breaking_json_str, encoding="utf-8") - expected_breaking = breaking_fixture_path.read_text(encoding="utf-8").strip() - assert json.loads(breaking_json_str) == json.loads(expected_breaking) + 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() @@ -356,12 +431,9 @@ def test_golden_fixtures_match(): ] 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) - - review_fixture_path = FIXTURES_DIR / "review_deprecate_decision.json" - review_fixture_path.write_text(review_json_str, encoding="utf-8") - expected_review = review_fixture_path.read_text(encoding="utf-8").strip() - assert json.loads(review_json_str) == json.loads(expected_review) + 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") @@ -369,12 +441,9 @@ def test_golden_fixtures_match(): _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) - - retired_fixture_path = FIXTURES_DIR / "block_retired_decision.json" - retired_fixture_path.write_text(retired_json_str, encoding="utf-8") - expected_retired = retired_fixture_path.read_text(encoding="utf-8").strip() - assert json.loads(retired_json_str) == json.loads(expected_retired) + 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() @@ -383,9 +452,6 @@ def test_golden_fixtures_match(): ) 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) - - invalid_fixture_path = FIXTURES_DIR / "block_validation_decision.json" - invalid_fixture_path.write_text(invalid_json_str, encoding="utf-8") - expected_invalid = invalid_fixture_path.read_text(encoding="utf-8").strip() - assert json.loads(invalid_json_str) == json.loads(expected_invalid) + invalid_json_str = serialize_public_governance_decision(invalid_pub, indent=2) + "\n" + expected_invalid = (FIXTURES_DIR / "block_validation_decision.json").read_text(encoding="utf-8") + assert invalid_json_str == expected_invalid From 09b9da07797db73d85fd514e080119dc22e965e5 Mon Sep 17 00:00:00 2001 From: ElliotSun Date: Sat, 29 Aug 2026 08:03:10 +1000 Subject: [PATCH 4/6] refactor(governance): align PublicEntityType with canonical entities and add typed mappers --- semapact/governance/public.py | 167 +++++++++++++++++------ tests/test_public_governance_decision.py | 10 ++ 2 files changed, 138 insertions(+), 39 deletions(-) diff --git a/semapact/governance/public.py b/semapact/governance/public.py index 3f8349f..d1f5b72 100644 --- a/semapact/governance/public.py +++ b/semapact/governance/public.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from typing import Any, Literal +from typing import Any, Literal, cast from pydantic import AliasChoices, BaseModel, ConfigDict, Field, JsonValue from semapact.governance.models import ( @@ -36,7 +36,6 @@ "PROPERTY", "RELATIONSHIP", "QUALITY", - "SERVER", ] PublicChangeDomain = Literal[ "IDENTITY", @@ -50,6 +49,119 @@ 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[str, PublicRequiredVersionBump] = { + "none": "none", + "patch": "patch", + "minor": "minor", + "major": "major", +} + + +def _map_decision(decision: DecisionResult | str) -> PublicDecisionResult: + if isinstance(decision, DecisionResult): + return _DECISION_MAP[decision] + val = str(decision) + if val in {"ALLOW", "REVIEW", "BLOCK"}: + return cast(PublicDecisionResult, val) + raise ValueError(f"Invalid decision for public contract: {decision}") + + +def _map_severity(severity: GovernanceSeverity | str) -> PublicSeverity: + if isinstance(severity, GovernanceSeverity): + return _SEVERITY_MAP[severity] + val = str(severity) + if val in {"ERROR", "WARNING", "INFO"}: + return cast(PublicSeverity, val) + raise ValueError(f"Invalid severity for public contract: {severity}") + + +def _map_change_type(change_type: GovernanceChangeType | str) -> PublicChangeType: + if isinstance(change_type, GovernanceChangeType): + return _CHANGE_TYPE_MAP[change_type] + val = str(change_type) + if val in {"ADD", "REMOVE", "MODIFY", "DEPRECATE"}: + return cast(PublicChangeType, val) + raise ValueError(f"Invalid change_type for public contract: {change_type}") + + +def _map_entity_type(entity_type: GovernanceEntityType | str) -> PublicEntityType: + if isinstance(entity_type, GovernanceEntityType): + return _ENTITY_TYPE_MAP[entity_type] + val = str(entity_type) + if val in {"CONTRACT", "SCHEMA", "PROPERTY", "RELATIONSHIP", "QUALITY"}: + return cast(PublicEntityType, val) + raise ValueError(f"Invalid entity_type for public contract: {entity_type}") + + +def _map_domain(domain: GovernanceChangeDomain | str) -> PublicChangeDomain: + if isinstance(domain, GovernanceChangeDomain): + return _DOMAIN_MAP[domain] + val = str(domain) + if val in {"IDENTITY", "VERSION", "LIFECYCLE", "STRUCTURE", "RELATIONSHIP", "QUALITY", "METADATA"}: + return cast(PublicChangeDomain, val) + raise ValueError(f"Invalid domain for public contract: {domain}") + + +def _map_evidence_source(source: GovernanceChangeEvidenceSource | str) -> PublicEvidenceSource: + if isinstance(source, GovernanceChangeEvidenceSource): + return _EVIDENCE_SOURCE_MAP[source] + val = str(source) + if val in {"MERGE_CONFLICT"}: + return cast(PublicEvidenceSource, val) + raise ValueError(f"Invalid evidence source for public contract: {source}") + + +def _map_required_bump(bump: str) -> PublicRequiredVersionBump: + if bump in _REQUIRED_BUMP_MAP: + return _REQUIRED_BUMP_MAP[bump] + raise ValueError(f"Invalid required_version_bump for public contract: {bump}") + + # ============================================================================== # Public Models # ============================================================================== @@ -219,15 +331,11 @@ def to_canonical_dict(self) -> dict[str, Any]: 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 = ( - reason.severity.value - if isinstance(reason.severity, GovernanceSeverity) - else str(reason.severity) - ) + 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, # type: ignore[arg-type] + severity=severity_val, message=reason.message, path=reason.path, details=details_copy, @@ -236,34 +344,18 @@ def _project_reason(reason: GovernanceReason) -> PublicGovernanceReasonV1: def _project_change_evidence(evidence: GovernanceChangeEvidence) -> PublicGovernanceChangeEvidenceV1: """Project internal GovernanceChangeEvidence to PublicGovernanceChangeEvidenceV1.""" - source_val = ( - evidence.source.value - if isinstance(evidence.source, GovernanceChangeEvidenceSource) - else str(evidence.source) - ) + source_val = _map_evidence_source(evidence.source) return PublicGovernanceChangeEvidenceV1( - source=source_val, # type: ignore[arg-type] + source=source_val, code=evidence.code, ) def _project_change(change: GovernanceChange) -> PublicGovernanceChangeV1: """Project internal GovernanceChange to PublicGovernanceChangeV1 with deterministic sorting.""" - change_type_val = ( - change.change_type.value - if isinstance(change.change_type, GovernanceChangeType) - else str(change.change_type) - ) - entity_type_val = ( - change.entity_type.value - if isinstance(change.entity_type, GovernanceEntityType) - else str(change.entity_type) - ) - domain_val = ( - change.domain.value - if isinstance(change.domain, GovernanceChangeDomain) - else str(change.domain) - ) + 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), @@ -277,14 +369,14 @@ def _project_change(change: GovernanceChange) -> PublicGovernanceChangeV1: }) ) return PublicGovernanceChangeV1( - change_type=change_type_val, # type: ignore[arg-type] - entity_type=entity_type_val, # type: ignore[arg-type] + 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, # type: ignore[arg-type] + domain=domain_val, breaking=change.breaking, reason_codes=reason_codes_val, evidence=projected_evidence, @@ -339,20 +431,17 @@ def to_public_governance_decision(decision: GovernanceDecision) -> PublicGoverna for c in projected_changes: all_reason_codes.update(c.reason_codes) - decision_val = ( - decision.decision.value - if isinstance(decision.decision, DecisionResult) - else str(decision.decision) - ) + decision_val = _map_decision(decision.decision) + bump_val = _map_required_bump(str(decision.required_version_bump)) return PublicGovernanceDecisionV1( schema_version="1", decision_id=decision.decision_id, - decision=decision_val, # type: ignore[arg-type] + decision=decision_val, contract_id=decision.contract_id, context=context, breaking=decision.breaking, - required_version_bump=str(decision.required_version_bump), # type: ignore[arg-type] + required_version_bump=bump_val, reason_codes=tuple(sorted(all_reason_codes)), reasons=projected_reasons, validation=validation, diff --git a/tests/test_public_governance_decision.py b/tests/test_public_governance_decision.py index 813aec4..e10c285 100644 --- a/tests/test_public_governance_decision.py +++ b/tests/test_public_governance_decision.py @@ -292,6 +292,16 @@ def test_public_literals_strict_validation(): "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", + ) + def test_public_decision_immutability_and_extra_forbid(): """Public governance models are frozen and reject extra fields.""" From 15d6a82296a6dff3c9885a47e021d671bedf2c9c Mon Sep 17 00:00:00 2001 From: ElliotSun Date: Sat, 29 Aug 2026 08:11:49 +1000 Subject: [PATCH 5/6] refactor(governance): enforce strict internal enum signatures on protocol mappers --- semapact/governance/public.py | 75 +++++++++++++++-------------------- 1 file changed, 32 insertions(+), 43 deletions(-) diff --git a/semapact/governance/public.py b/semapact/governance/public.py index d1f5b72..57972f5 100644 --- a/semapact/governance/public.py +++ b/semapact/governance/public.py @@ -3,9 +3,10 @@ from __future__ import annotations import json -from typing import Any, Literal, cast +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, @@ -94,72 +95,60 @@ GovernanceChangeEvidenceSource.MERGE_CONFLICT: "MERGE_CONFLICT", } -_REQUIRED_BUMP_MAP: dict[str, PublicRequiredVersionBump] = { +_REQUIRED_BUMP_MAP: dict[RequiredBump, PublicRequiredVersionBump] = { "none": "none", - "patch": "patch", "minor": "minor", "major": "major", } -def _map_decision(decision: DecisionResult | str) -> PublicDecisionResult: - if isinstance(decision, DecisionResult): +def _map_decision(decision: DecisionResult) -> PublicDecisionResult: + try: return _DECISION_MAP[decision] - val = str(decision) - if val in {"ALLOW", "REVIEW", "BLOCK"}: - return cast(PublicDecisionResult, val) - raise ValueError(f"Invalid decision for public contract: {decision}") + except KeyError: + raise ValueError(f"Unsupported internal DecisionResult: {decision!r}") from None -def _map_severity(severity: GovernanceSeverity | str) -> PublicSeverity: - if isinstance(severity, GovernanceSeverity): +def _map_severity(severity: GovernanceSeverity) -> PublicSeverity: + try: return _SEVERITY_MAP[severity] - val = str(severity) - if val in {"ERROR", "WARNING", "INFO"}: - return cast(PublicSeverity, val) - raise ValueError(f"Invalid severity for public contract: {severity}") + except KeyError: + raise ValueError(f"Unsupported internal GovernanceSeverity: {severity!r}") from None -def _map_change_type(change_type: GovernanceChangeType | str) -> PublicChangeType: - if isinstance(change_type, GovernanceChangeType): +def _map_change_type(change_type: GovernanceChangeType) -> PublicChangeType: + try: return _CHANGE_TYPE_MAP[change_type] - val = str(change_type) - if val in {"ADD", "REMOVE", "MODIFY", "DEPRECATE"}: - return cast(PublicChangeType, val) - raise ValueError(f"Invalid change_type for public contract: {change_type}") + except KeyError: + raise ValueError(f"Unsupported internal GovernanceChangeType: {change_type!r}") from None -def _map_entity_type(entity_type: GovernanceEntityType | str) -> PublicEntityType: - if isinstance(entity_type, GovernanceEntityType): +def _map_entity_type(entity_type: GovernanceEntityType) -> PublicEntityType: + try: return _ENTITY_TYPE_MAP[entity_type] - val = str(entity_type) - if val in {"CONTRACT", "SCHEMA", "PROPERTY", "RELATIONSHIP", "QUALITY"}: - return cast(PublicEntityType, val) - raise ValueError(f"Invalid entity_type for public contract: {entity_type}") + except KeyError: + raise ValueError(f"Unsupported internal GovernanceEntityType: {entity_type!r}") from None -def _map_domain(domain: GovernanceChangeDomain | str) -> PublicChangeDomain: - if isinstance(domain, GovernanceChangeDomain): +def _map_domain(domain: GovernanceChangeDomain) -> PublicChangeDomain: + try: return _DOMAIN_MAP[domain] - val = str(domain) - if val in {"IDENTITY", "VERSION", "LIFECYCLE", "STRUCTURE", "RELATIONSHIP", "QUALITY", "METADATA"}: - return cast(PublicChangeDomain, val) - raise ValueError(f"Invalid domain for public contract: {domain}") + except KeyError: + raise ValueError(f"Unsupported internal GovernanceChangeDomain: {domain!r}") from None -def _map_evidence_source(source: GovernanceChangeEvidenceSource | str) -> PublicEvidenceSource: - if isinstance(source, GovernanceChangeEvidenceSource): +def _map_evidence_source(source: GovernanceChangeEvidenceSource) -> PublicEvidenceSource: + try: return _EVIDENCE_SOURCE_MAP[source] - val = str(source) - if val in {"MERGE_CONFLICT"}: - return cast(PublicEvidenceSource, val) - raise ValueError(f"Invalid evidence source for public contract: {source}") + except KeyError: + raise ValueError(f"Unsupported internal GovernanceChangeEvidenceSource: {source!r}") from None -def _map_required_bump(bump: str) -> PublicRequiredVersionBump: - if bump in _REQUIRED_BUMP_MAP: +def _map_required_bump(bump: RequiredBump) -> PublicRequiredVersionBump: + try: return _REQUIRED_BUMP_MAP[bump] - raise ValueError(f"Invalid required_version_bump for public contract: {bump}") + except KeyError: + raise ValueError(f"Unsupported internal RequiredBump: {bump!r}") from None # ============================================================================== @@ -432,7 +421,7 @@ def to_public_governance_decision(decision: GovernanceDecision) -> PublicGoverna all_reason_codes.update(c.reason_codes) decision_val = _map_decision(decision.decision) - bump_val = _map_required_bump(str(decision.required_version_bump)) + bump_val = _map_required_bump(decision.required_version_bump) return PublicGovernanceDecisionV1( schema_version="1", From 8f7bbf9d39a31c29820ec4043e043ac9904fc26b Mon Sep 17 00:00:00 2001 From: ElliotSun Date: Sat, 29 Aug 2026 08:56:14 +1000 Subject: [PATCH 6/6] refactor(governance): align PublicRequiredVersionBump with lifecycle policy bumps (none, minor, major) --- semapact/governance/public.py | 2 +- tests/test_public_governance_decision.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/semapact/governance/public.py b/semapact/governance/public.py index 57972f5..ca9205a 100644 --- a/semapact/governance/public.py +++ b/semapact/governance/public.py @@ -28,7 +28,7 @@ # ============================================================================== PublicDecisionResult = Literal["ALLOW", "REVIEW", "BLOCK"] -PublicRequiredVersionBump = Literal["none", "patch", "minor", "major"] +PublicRequiredVersionBump = Literal["none", "minor", "major"] PublicSeverity = Literal["ERROR", "WARNING", "INFO"] PublicChangeType = Literal["ADD", "REMOVE", "MODIFY", "DEPRECATE"] PublicEntityType = Literal[ diff --git a/tests/test_public_governance_decision.py b/tests/test_public_governance_decision.py index e10c285..4d74e83 100644 --- a/tests/test_public_governance_decision.py +++ b/tests/test_public_governance_decision.py @@ -302,6 +302,24 @@ def test_public_literals_strict_validation(): 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."""