diff --git a/CHANGELOG.md b/CHANGELOG.md index b08814b3e4..a8f8fb6b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,10 +41,58 @@ contract = ModelContractCompute( ``` **Note:** Other models retain their own `version` fields (not renamed): -- `ModelHandlerContract.version` - `ModelProfileReference.version` - `ModelValidatorSubcontract.version` +#### Handler Contract Version Field Migration [OMN-1436] + +**`ModelHandlerContract.version` migrated to `contract_version: ModelSemVer`** with strict enforcement. Handler contracts now require the structured `contract_version` field instead of the legacy string-based `version` field. + +| Before (v0.8.x) | After (v0.9.0) | +|-----------------|----------------| +| `version: "1.0.0"` | `contract_version: {major: 1, minor: 0, patch: 0}` | +| `ModelHandlerContract(version="1.0.0")` | `ModelHandlerContract(contract_version=ModelSemVer(...))` | + +**YAML Migration:** +```yaml +# Before (v0.8.x) +name: my_handler +version: "1.0.0" +handler_kind: compute + +# After (v0.9.0) +name: my_handler +contract_version: + major: 1 + minor: 0 + patch: 0 +handler_kind: compute +``` + +**Python Migration:** +```python +# Before (v0.8.x) +from omnibase_core.models.runtime.model_handler_contract import ModelHandlerContract + +contract = ModelHandlerContract( + name="my_handler", + version="1.0.0", + handler_kind=EnumHandlerKind.COMPUTE, +) + +# After (v0.9.0) +from omnibase_core.models.runtime.model_handler_contract import ModelHandlerContract +from omnibase_core.models.primitives.model_semver import ModelSemVer + +contract = ModelHandlerContract( + name="my_handler", + contract_version=ModelSemVer(major=1, minor=0, patch=0), + handler_kind=EnumHandlerKind.COMPUTE, +) +``` + +**Validation:** Strict contracts (those inheriting from `ModelContractBase` with `is_strict_contract()` returning `True`) now require `contract_version` to be explicitly set. Loading a handler contract YAML without `contract_version` will raise a validation error. + ### Added - **Guardrail Validator**: `ValidatorContractLinter` now rejects YAML contracts using deprecated `version:` field, enforcing migration to `contract_version:` diff --git a/docs/contracts/HANDLER_CONTRACT_GUIDE.md b/docs/contracts/HANDLER_CONTRACT_GUIDE.md index f6679d00f4..b5dafac97e 100644 --- a/docs/contracts/HANDLER_CONTRACT_GUIDE.md +++ b/docs/contracts/HANDLER_CONTRACT_GUIDE.md @@ -8,6 +8,8 @@ > **New in v0.4.1**: ModelHandlerContract provides the complete authoring surface for ONEX handlers with capability-based dependencies and embedded behavior descriptors. +> **Migration Note (OMN-1436)**: As of v0.4.2, contracts use `contract_version: ModelSemVer` (structured object) instead of the deprecated `version: str` field. The string-based `version` field has been removed. All contracts must now use the structured version format with explicit `major`, `minor`, and `patch` fields. + ## Table of Contents 1. [Overview](#overview) @@ -35,12 +37,13 @@ ```python from omnibase_core.models.contracts import ModelHandlerContract +from omnibase_core.models.primitives import ModelSemVer from omnibase_core.models.runtime import ModelHandlerBehaviorDescriptor contract = ModelHandlerContract( handler_id="compute.json.transformer", name="JSON Transformer", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehaviorDescriptor( handler_kind="compute", purity="pure", @@ -179,10 +182,27 @@ Do you want the handler_id to indicate the handler kind? ### Identity Fields ```python -handler_id: str # Unique identifier with dot-notation (required) -name: str # Human-readable display name (required) -version: str # Semantic version string, e.g., "1.0.0" (required) -description: str # Optional detailed description +handler_id: str # Unique identifier with dot-notation (required) +name: str # Human-readable display name (required) +contract_version: ModelSemVer # Structured semantic version (required) +description: str # Optional detailed description +``` + +The `contract_version` field uses `ModelSemVer`, a structured object with explicit version components: + +```python +from omnibase_core.models.primitives import ModelSemVer + +contract_version=ModelSemVer(major=1, minor=0, patch=0) +``` + +Or in YAML format: + +```yaml +contract_version: + major: 1 + minor: 0 + patch: 0 ``` ### Behavior Configuration @@ -240,12 +260,13 @@ metadata: dict[str, Any] # Extensibility metadata ```python from omnibase_core.models.contracts import ModelHandlerContract +from omnibase_core.models.primitives import ModelSemVer from omnibase_core.models.runtime import ModelHandlerBehaviorDescriptor contract = ModelHandlerContract( handler_id="node.my.handler", name="My Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehaviorDescriptor(handler_kind="compute"), input_model="myapp.models.Input", output_model="myapp.models.Output", @@ -261,6 +282,7 @@ from omnibase_core.models.contracts import ( ModelExecutionConstraints, ModelRequirementSet, ) +from omnibase_core.models.primitives import ModelSemVer from omnibase_core.models.runtime import ( ModelHandlerBehaviorDescriptor, ModelDescriptorRetryPolicy, @@ -271,7 +293,7 @@ contract = ModelHandlerContract( # Identity handler_id="effect.email.sender", name="Email Sender Effect", - version="2.0.0", + contract_version=ModelSemVer(major=2, minor=0, patch=0), description="Sends emails via SMTP with retry and circuit breaker protection", # Behavior @@ -514,7 +536,7 @@ handler_id="node.handler_123" |-------|------|----------|-------------| | `handler_id` | `str` | Yes | Unique identifier with prefix convention | | `name` | `str` | Yes | Human-readable name | -| `version` | `str` | Yes | Semantic version | +| `contract_version` | `ModelSemVer` | Yes | Structured semantic version with `major`, `minor`, `patch` | | `description` | `str` | No | Detailed description | | `descriptor` | `ModelHandlerBehaviorDescriptor` | Yes | Runtime behavior | | `capability_inputs` | `list[ModelCapabilityDependency]` | No | Required capabilities | @@ -538,6 +560,8 @@ from omnibase_core.models.contracts import ( ModelRequirementSet, ) +from omnibase_core.models.primitives import ModelSemVer + from omnibase_core.models.runtime import ( ModelHandlerBehaviorDescriptor, ModelDescriptorRetryPolicy, diff --git a/examples/contracts/handlers/compute_handler.yaml b/examples/contracts/handlers/compute_handler.yaml index 56328365f0..c0a7fb3e91 100644 --- a/examples/contracts/handlers/compute_handler.yaml +++ b/examples/contracts/handlers/compute_handler.yaml @@ -10,7 +10,10 @@ handler_id: compute.schema.validator name: Schema Validator -version: "1.0.0" +contract_version: + major: 1 + minor: 0 + patch: 0 description: Validates input data against JSON schemas descriptor: diff --git a/examples/contracts/handlers/effect_handler.yaml b/examples/contracts/handlers/effect_handler.yaml index eeb05e679b..c4ffab773f 100644 --- a/examples/contracts/handlers/effect_handler.yaml +++ b/examples/contracts/handlers/effect_handler.yaml @@ -10,7 +10,10 @@ handler_id: effect.database.user_repository name: User Repository -version: "2.0.0" +contract_version: + major: 2 + minor: 0 + patch: 0 description: Handles user persistence operations with PostgreSQL descriptor: diff --git a/examples/contracts/handlers/reducer_handler.yaml b/examples/contracts/handlers/reducer_handler.yaml index 1d10dc32f7..d07d7e5063 100644 --- a/examples/contracts/handlers/reducer_handler.yaml +++ b/examples/contracts/handlers/reducer_handler.yaml @@ -10,7 +10,10 @@ handler_id: reducer.registration.user name: User Registration Reducer -version: "1.2.0" +contract_version: + major: 1 + minor: 2 + patch: 0 description: Manages user registration lifecycle using FSM descriptor: diff --git a/examples/demo/handlers/support_assistant/support_assistant.yaml b/examples/demo/handlers/support_assistant/support_assistant.yaml index 58fccea5da..f6dbc63ca9 100644 --- a/examples/demo/handlers/support_assistant/support_assistant.yaml +++ b/examples/demo/handlers/support_assistant/support_assistant.yaml @@ -31,7 +31,10 @@ handler_id: effect.demo.support_assistant name: Support Assistant Demo Handler # Semantic versioning: MAJOR.MINOR.PATCH # Consumers should depend on ^1.x (compatible with any 1.x.x version) -version: "1.0.0" +contract_version: + major: 1 + minor: 0 + patch: 0 # Note: LLM responses may vary between invocations due to model temperature, # sampling, and provider state - this is inherent to LLM-based handlers. description: AI support assistant that responds to user requests with structured JSON output diff --git a/examples/demo/model-validate/contract.yaml b/examples/demo/model-validate/contract.yaml index e334b0ae00..1ebb69c065 100644 --- a/examples/demo/model-validate/contract.yaml +++ b/examples/demo/model-validate/contract.yaml @@ -4,6 +4,7 @@ # Node Type: COMPUTE_GENERIC name: support-ticket-classifier +node_type: COMPUTE_GENERIC contract_version: major: 1 minor: 0 diff --git a/scripts/compute_contract_fingerprint.py b/scripts/compute_contract_fingerprint.py index 51aae87fe0..d9de86881a 100644 --- a/scripts/compute_contract_fingerprint.py +++ b/scripts/compute_contract_fingerprint.py @@ -85,8 +85,9 @@ # ============================================================================== # Mapping of node_type values to strict contract model classes -# These require all base fields (name, version, description, input_model, output_model) +# These require all base fields (name, contract_version, description, input_model, output_model) # Note: Keys are uppercase to match EnumNodeType values directly +# Note: As of OMN-1431, 'version' field is deprecated; use 'contract_version' instead NODE_TYPE_TO_STRICT_MODEL: dict[str, type[BaseModel]] = { # COMPUTE types (maps to ModelContractCompute) "COMPUTE_GENERIC": ModelContractCompute, @@ -112,29 +113,34 @@ } -def is_strict_contract(contract_data: dict[str, object]) -> bool: - """Check if contract data matches strict typed contract schema. +def validate_strict_contract(contract_data: dict[str, object]) -> bool: + """Validate contract data and determine if it matches strict typed schema. + + This is the canonical validation function that enforces contract rules. + Use this in loaders and enforcement paths where you want errors to propagate. - Determines whether the contract should be validated against strict typed - models (ModelContractCompute, etc.) or the flexible ModelYamlContract. + IMPORTANT: As of OMN-1431/OMN-1436, all contracts MUST use 'contract_version', + not 'version'. Contracts using the deprecated 'version' field will be rejected. + + Validation Rules: + 1. If has deprecated 'version' field -> REJECT with error (always, even if + 'contract_version' is also present) + 2. If has 'contract_version' AND all strict fields -> strict (returns True) + 3. If has 'contract_version' but missing strict fields -> flexible (returns False) + 4. If missing 'contract_version' but has strict fields -> REJECT with error (requires explicit versioning) + 5. Otherwise -> flexible (returns False, default for compatibility) Strict vs Flexible Contracts: **Strict Contracts** use: - - 'version' field (ModelSemVer format, e.g., '1.0.0') + - 'contract_version' field (required for all contracts) - Required base fields: 'name', 'input_model', 'output_model', 'description' - Type-specific validation rules **Flexible YAML Contracts** use: - - 'contract_version' field (alternative versioning) + - 'contract_version' field (required for all contracts) - Arbitrary extra fields allowed - Minimal validation for maximum compatibility - Detection Logic: - 1. If has 'version' (not 'contract_version') AND all strict fields -> strict - 2. If has 'contract_version' -> flexible (regardless of other fields) - 3. If missing both version fields but has strict fields -> try strict - 4. Otherwise -> flexible (default for compatibility) - Args: contract_data: Parsed YAML contract data as a dictionary. @@ -142,40 +148,137 @@ def is_strict_contract(contract_data: dict[str, object]) -> bool: True if contract should use strict typed models (ModelContractCompute, etc.). False if contract should use flexible ModelYamlContract. + Raises: + ModelOnexError: If contract uses deprecated 'version' field instead of + 'contract_version'. Error code: CONTRACT_VALIDATION_ERROR. + This is raised even if 'contract_version' is also present. + Examples: - >>> is_strict_contract({'version': '1.0.0', 'name': 'Test', ...}) - True - >>> is_strict_contract({'contract_version': '1.0', 'name': 'Test'}) - False - >>> is_strict_contract({'random_field': 'value'}) - False + Strict contract with all required fields returns True:: + + validate_strict_contract({'contract_version': '1.0.0', 'name': 'Test', ...}) + # Returns: True + + Flexible contract (missing strict fields) returns False:: + + validate_strict_contract({'contract_version': '1.0', 'name': 'Test'}) + # Returns: False + + Deprecated 'version' field raises error:: + + validate_strict_contract({'version': '1.0.0', 'name': 'Test'}) + # Raises ModelOnexError: Contract uses deprecated 'version' field + + Both 'version' and 'contract_version' present still raises error:: + + validate_strict_contract({'version': '1.0.0', 'contract_version': '1.0.0'}) + # Raises ModelOnexError: Deprecated 'version' field rejected + + Missing 'contract_version' with strict fields raises error:: + + validate_strict_contract({ + 'name': 'Test', 'description': '...', 'input_model': '...', 'output_model': '...' + }) + # Raises ModelOnexError: Missing 'contract_version' with strict fields """ - # Strict contracts use 'version', flexible YAML uses 'contract_version' has_version = "version" in contract_data has_contract_version = "contract_version" in contract_data + # REJECT contracts using deprecated 'version' field (OMN-1431/OMN-1436) + # This applies even if 'contract_version' is also present - no "both present" loophole + if has_version: + raise ModelOnexError( + message=( + "Contract uses deprecated 'version' field. " + "All contracts must use 'contract_version' instead. " + "See OMN-1431 for migration details." + ), + error_code=EnumCoreErrorCode.CONTRACT_VALIDATION_ERROR, + context={ + "deprecated_field": "version", + "required_field": "contract_version", + "suggestion": "Replace 'version' with 'contract_version' in your contract YAML", + "has_contract_version": has_contract_version, + }, + ) + # Strict contracts require name, input_model, output_model, description has_strict_fields = all( field in contract_data for field in ("name", "input_model", "output_model", "description") ) - # If has 'version' (not 'contract_version') and strict fields, it's strict - if has_version and not has_contract_version and has_strict_fields: + # If has 'contract_version' and strict fields, it's strict + if has_contract_version and has_strict_fields: return True - # If has 'contract_version', it's flexible YAML + # If has 'contract_version' but missing strict fields, it's flexible if has_contract_version: return False - # If missing both version fields but has strict fields, try strict + # If missing 'contract_version' but has strict fields, reject - require explicit versioning if has_strict_fields: - return True + raise ModelOnexError( + message=( + "Contract has strict fields but missing 'contract_version'. " + "All strict contracts must include 'contract_version' field. " + "See OMN-1431 for migration details." + ), + error_code=EnumCoreErrorCode.CONTRACT_VALIDATION_ERROR, + context={ + "has_strict_fields": True, + "missing_field": "contract_version", + "suggestion": "Add 'contract_version' field to your contract YAML", + }, + ) # Default to flexible for better compatibility return False +def is_strict_contract(contract_data: dict[str, object]) -> bool: + """Check if contract data matches strict typed contract schema (predicate only). + + This is a pure predicate wrapper around validate_strict_contract() that + never raises exceptions. Use this for branching logic and "soft" checks + where you don't want exceptions to propagate. + + For enforcement paths (loaders, validators), use validate_strict_contract() + directly to get detailed error messages. + + Args: + contract_data: Parsed YAML contract data as a dictionary. + + Returns: + True if contract should use strict typed models (ModelContractCompute, etc.). + False if contract should use flexible ModelYamlContract OR if validation fails. + + Examples: + Strict contract with all required fields returns True:: + + is_strict_contract({'contract_version': '1.0.0', 'name': 'Test', ...}) + # Returns: True + + Flexible contract (missing strict fields) returns False:: + + is_strict_contract({'contract_version': '1.0', 'name': 'Test'}) + # Returns: False + + Deprecated 'version' field returns False (no exception):: + + is_strict_contract({'version': '1.0.0', 'name': 'Test'}) + # Returns: False (swallows the ModelOnexError from validate_strict_contract) + + See Also: + validate_strict_contract: The canonical validation function that raises + ModelOnexError with detailed context for invalid contracts. + """ + try: + return validate_strict_contract(contract_data) + except ModelOnexError: + return False + + def detect_contract_model(contract_data: dict[str, object]) -> type[BaseModel]: """Detect the appropriate contract model class from YAML data. @@ -184,26 +287,29 @@ def detect_contract_model(contract_data: dict[str, object]) -> type[BaseModel]: Falls back to ModelYamlContract for maximum compatibility with non-standard or legacy contracts. + IMPORTANT: As of OMN-1431/OMN-1436, all contracts MUST use 'contract_version', + not the deprecated 'version' field. Contracts using 'version' will be rejected. + Detection Strategy: 1. **Validate node_type**: Ensures node_type field exists and is a string. Raises ModelOnexError if missing or invalid type. - 2. **Check for strict contract**: Uses is_strict_contract() to determine if - the contract has strict typed fields ('version' vs 'contract_version', - required base fields like 'name', 'input_model', 'output_model'). + 2. **Check for strict contract**: Uses validate_strict_contract() to determine if + the contract has strict typed fields ('contract_version' and required + base fields like 'name', 'input_model', 'output_model'). + NOTE: Contracts using deprecated 'version' field are REJECTED. 3. **Map to strict model**: If strict and node_type is in NODE_TYPE_TO_STRICT_MODEL, returns the corresponding typed model class (e.g., ModelContractCompute). 4. **Fallback to flexible**: Returns ModelYamlContract if: - - The contract is not strict (uses 'contract_version', missing base fields) + - The contract is not strict (missing base fields) - The node_type is not in NODE_TYPE_TO_STRICT_MODEL (new/custom types) Fallback Behavior: When node_type is not found in NODE_TYPE_TO_STRICT_MODEL mapping, this function falls back to ModelYamlContract. This ensures: - Forward compatibility with new node types added to the system - - Backward compatibility with legacy contracts using non-standard fields - Maximum flexibility for experimental or custom contracts The fallback does NOT raise an error because: @@ -213,8 +319,9 @@ def detect_contract_model(contract_data: dict[str, object]) -> type[BaseModel]: Args: contract_data: Parsed YAML contract data as a dictionary. Must contain - at minimum a 'node_type' field. Other required fields depend on - whether it's a strict or flexible contract. + at minimum a 'node_type' field. Must use 'contract_version' (not + deprecated 'version'). Other required fields depend on whether + it's a strict or flexible contract. Returns: Pydantic model class appropriate for the contract type: @@ -226,27 +333,43 @@ def detect_contract_model(contract_data: dict[str, object]) -> type[BaseModel]: Raises: ModelOnexError: If node_type field is missing from contract_data. - Error code: VALIDATION_ERROR + Error code: CONTRACT_VALIDATION_ERROR Context includes: contract_keys (list of keys in data) Suggestion provided for adding node_type field. ModelOnexError: If node_type is not a string (e.g., int, list, dict). - Error code: VALIDATION_ERROR + Error code: CONTRACT_VALIDATION_ERROR Context includes: node_type_value, node_type_type + ModelOnexError: If contract uses deprecated 'version' field. + Error code: CONTRACT_VALIDATION_ERROR + Suggestion to migrate to 'contract_version'. + Examples: - >>> detect_contract_model({'node_type': 'COMPUTE_GENERIC', 'version': '1.0.0', ...}) - ModelContractCompute + Known strict contract type returns typed model:: + + detect_contract_model({'node_type': 'COMPUTE_GENERIC', 'contract_version': '1.0.0', ...}) + # Returns: ModelContractCompute + + Unknown node_type falls back to flexible model:: - >>> detect_contract_model({'node_type': 'CUSTOM_TYPE', 'contract_version': '1.0'}) - ModelYamlContract # Falls back for unknown type + detect_contract_model({'node_type': 'CUSTOM_TYPE', 'contract_version': '1.0'}) + # Returns: ModelYamlContract - >>> detect_contract_model({'name': 'test'}) # Missing node_type - ModelOnexError: Contract missing required 'node_type' field + Missing node_type raises error:: + + detect_contract_model({'name': 'test'}) + # Raises ModelOnexError: Contract missing required 'node_type' field + + Deprecated 'version' field raises error:: + + detect_contract_model({'node_type': 'COMPUTE_GENERIC', 'version': '1.0.0'}) + # Raises ModelOnexError: Contract uses deprecated 'version' field See Also: - NODE_TYPE_TO_STRICT_MODEL: Mapping of node_type values to model classes. - - is_strict_contract(): Determines if contract uses strict typed schema. + - validate_strict_contract(): Validates and determines if contract uses strict schema. + - is_strict_contract(): Pure predicate wrapper (doesn't raise exceptions). - ModelYamlContract: Flexible contract model for non-standard contracts. """ node_type = contract_data.get("node_type") @@ -254,21 +377,28 @@ def detect_contract_model(contract_data: dict[str, object]) -> type[BaseModel]: if node_type is None: raise ModelOnexError( message="Contract missing required 'node_type' field", - error_code=EnumCoreErrorCode.VALIDATION_ERROR, - contract_keys=list(contract_data.keys()), - suggestion="Add 'node_type' field (e.g., COMPUTE_GENERIC, REDUCER_GENERIC)", + error_code=EnumCoreErrorCode.CONTRACT_VALIDATION_ERROR, + context={ + "contract_keys": list(contract_data.keys()), + "suggestion": "Add 'node_type' field (e.g., COMPUTE_GENERIC, REDUCER_GENERIC)", + }, ) if not isinstance(node_type, str): raise ModelOnexError( message=f"Invalid node_type: expected string, got {type(node_type).__name__}", - error_code=EnumCoreErrorCode.VALIDATION_ERROR, - node_type_value=str(node_type), - node_type_type=type(node_type).__name__, + error_code=EnumCoreErrorCode.CONTRACT_VALIDATION_ERROR, + context={ + "node_type_value": str(node_type), + "node_type_type": type(node_type).__name__, + }, ) # Check if this is a strict typed contract - if is_strict_contract(contract_data): + # NOTE: validate_strict_contract() raises ModelOnexError for deprecated 'version' field + # or missing 'contract_version' with strict fields. Errors propagate intentionally + # to enforce contract standards - callers should handle or let propagate. + if validate_strict_contract(contract_data): model_class = NODE_TYPE_TO_STRICT_MODEL.get(node_type) if model_class is not None: return model_class diff --git a/scripts/regenerate_fingerprints.py b/scripts/regenerate_fingerprints.py index b330a86e17..499518e86b 100644 --- a/scripts/regenerate_fingerprints.py +++ b/scripts/regenerate_fingerprints.py @@ -196,21 +196,25 @@ def detect_contract_model(contract_data: dict[str, object]) -> type[BaseModel] | Returns: Pydantic model class for the contract type, or None if not determinable. + + Note: + As of OMN-1431, only 'contract_version' is recognized as the valid version field. + Files using the deprecated 'version' field should be migrated before processing. """ node_type = contract_data.get("node_type") if node_type is None or not isinstance(node_type, str): return None - # Check for strict contract (has version and required fields) + # Check for strict contract (has contract_version and required fields) + # Note: 'version' field is deprecated per OMN-1431 - only 'contract_version' is valid has_strict_fields = all( field in contract_data for field in ("name", "input_model", "output_model", "description") ) - has_version = "version" in contract_data has_contract_version = "contract_version" in contract_data - if has_version and not has_contract_version and has_strict_fields: + if has_contract_version and has_strict_fields: model_class = NODE_TYPE_TO_MODEL.get(node_type.upper()) if model_class is not None: return model_class @@ -609,6 +613,26 @@ def regenerate_fingerprint( skip_reason="Missing node_type field (not an ONEX contract)", ) + # Check for deprecated 'version' field (OMN-1431: version -> contract_version rename) + # IMPORTANT: Reject ANY presence of 'version' field, even if 'contract_version' is also + # present. This is consistent with validate_strict_contract() in + # src/omnibase_core/validation/validator_contracts.py which rejects contracts containing + # the deprecated field regardless of whether the new field exists. + has_version = "version" in contract_data + if has_version: + return RegenerateResult( + file_path=file_path, + old_fingerprint=None, + new_fingerprint=None, + changed=False, + skipped=True, + skip_reason=( + "Contains deprecated 'version' field. " + "Rename 'version' to 'contract_version' before " + "regenerating fingerprint (see OMN-1431)" + ), + ) + # Get existing fingerprint old_fingerprint = contract_data.get("fingerprint") if old_fingerprint and not isinstance(old_fingerprint, str): diff --git a/scripts/validation/handler_contract_validator.py b/scripts/validation/handler_contract_validator.py index ee9ace0041..16c5daab56 100644 --- a/scripts/validation/handler_contract_validator.py +++ b/scripts/validation/handler_contract_validator.py @@ -33,7 +33,7 @@ yaml_data = { "handler_id": "compute.schema.validator", "name": "Schema Validator", - "version": "1.0.0", + "contract_version": {"major": 1, "minor": 0, "patch": 0}, "descriptor": { "handler_kind": "compute", "purity": "pure", @@ -75,6 +75,27 @@ class MinimalObservabilityLevel: VALID_VALUES = {"minimal", "standard", "verbose"} +class MinimalSemVer(BaseModel): + """Minimal semantic version model for handler contract validation. + + Validates structured version format: {major: X, minor: Y, patch: Z} + """ + + model_config = ConfigDict(extra="forbid") + + major: int = Field(..., ge=0, description="Major version number") + minor: int = Field(..., ge=0, description="Minor version number") + patch: int = Field(..., ge=0, description="Patch version number") + prerelease: tuple[str | int, ...] | list[str | int] | None = Field( + default=None, + description="Optional prerelease identifiers", + ) + build: tuple[str, ...] | list[str] | None = Field( + default=None, + description="Optional build metadata", + ) + + class MinimalRetryPolicy(BaseModel): """Minimal retry policy model for handler contracts.""" @@ -286,7 +307,7 @@ class MinimalExecutionConstraints(BaseModel): class MinimalHandlerContract(BaseModel): """Pydantic model for validating handler contract YAML files without circular imports. - Validates required fields: handler_id, name, version, descriptor, input_model, output_model. + Validates required fields: handler_id, name, contract_version, descriptor, input_model, output_model. """ model_config = ConfigDict( @@ -311,10 +332,9 @@ class MinimalHandlerContract(BaseModel): description="Human-readable display name", ) - version: str = Field( + contract_version: MinimalSemVer = Field( ..., - pattern=r"^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$", - description="Semantic version string", + description="Structured semantic version {major, minor, patch}", ) description: str | None = Field( diff --git a/scripts/validation/validate-contracts.py b/scripts/validation/validate-contracts.py index 8a9ab8ebe0..01ef3828a7 100755 --- a/scripts/validation/validate-contracts.py +++ b/scripts/validation/validate-contracts.py @@ -143,8 +143,24 @@ def validate_yaml_file(file_path: Path) -> list[str]: # Basic fallback validation for handler contracts if "name" not in content: errors.append("Missing required field: name") - if "version" not in content: - errors.append("Missing required field: version") + if "version" in content: + errors.append( + "Deprecated field: version. Use contract_version instead." + ) + if "contract_version" not in content: + errors.append("Missing required field: contract_version") + else: + # Validate contract_version is a structured object (dict with major/minor/patch) + cv = content.get("contract_version") + if not isinstance(cv, dict): + errors.append( + "contract_version must be a structured object " + "{major: X, minor: Y, patch: Z}, not a string" + ) + elif not all(k in cv for k in ("major", "minor", "patch")): + errors.append( + "contract_version must have major, minor, and patch fields" + ) if "descriptor" not in content: errors.append("Missing required field: descriptor") if "input_model" not in content: diff --git a/scripts/validation/validate-string-versions.py b/scripts/validation/validate-string-versions.py index 9567dcbfc1..1c06f73e5a 100755 --- a/scripts/validation/validate-string-versions.py +++ b/scripts/validation/validate-string-versions.py @@ -76,7 +76,7 @@ class ValidationViolation(NamedTuple): "archive", # Exclude archived code "archived", # Exclude archived code (alternative naming) "tests", # Exclude test files - "examples", # Exclude examples - ModelHandlerContract uses str for version field + "examples", # Exclude examples - may contain demonstration code with various version formats "protocols", # Exclude Protocol classes (see rationale above) ] diff --git a/src/omnibase_core/contracts/contract_hash_registry.py b/src/omnibase_core/contracts/contract_hash_registry.py index 480ba84f57..816f324c12 100644 --- a/src/omnibase_core/contracts/contract_hash_registry.py +++ b/src/omnibase_core/contracts/contract_hash_registry.py @@ -253,12 +253,8 @@ def compute_contract_fingerprint( if config is None: config = ModelContractNormalizationConfig() - # Extract version from contract using duck typing for field name polymorphism - # ModelContractBase: contract_version, ModelHandlerContract: version - # TODO(OMN-1436): Remove fallback when ModelHandlerContract migrates to contract_version - version_data = getattr(contract, "contract_version", None) or getattr( - contract, "version", None - ) + # Extract version from contract (node contracts use contract_version) + version_data = getattr(contract, "contract_version", None) if version_data is None: # Default to 0.0.0 if no version specified version = ModelContractVersion(major=0, minor=0, patch=0) diff --git a/src/omnibase_core/merge/contract_merge_engine.py b/src/omnibase_core/merge/contract_merge_engine.py index 744742f6ee..91a8d4fd84 100644 --- a/src/omnibase_core/merge/contract_merge_engine.py +++ b/src/omnibase_core/merge/contract_merge_engine.py @@ -48,6 +48,7 @@ from omnibase_core.models.contracts.model_handler_contract import ModelHandlerContract from omnibase_core.models.errors.model_onex_error import ModelOnexError from omnibase_core.models.merge.model_merge_conflict import ModelMergeConflict +from omnibase_core.models.primitives.model_semver import ModelSemVer from omnibase_core.models.runtime.model_handler_behavior import ModelHandlerBehavior if TYPE_CHECKING: @@ -271,10 +272,9 @@ def merge( changes_applied.append(f"name: {base.name} -> {patch.name}") # Version handling: patch uses ModelSemVer, base uses ModelSemVer - merged_version = ( - str(patch.node_version) - if patch.node_version - else str(base.contract_version) + # Keep as ModelSemVer for ModelHandlerContract.contract_version field + merged_contract_version: ModelSemVer = ( + patch.node_version if patch.node_version else base.contract_version ) # Track version override @@ -372,7 +372,7 @@ def merge( result = ModelHandlerContract( handler_id=handler_id, name=merged_name, - version=merged_version, + contract_version=merged_contract_version, description=merged_description, descriptor=merged_behavior, capability_inputs=merged_capability_inputs, diff --git a/src/omnibase_core/models/contracts/model_handler_contract.py b/src/omnibase_core/models/contracts/model_handler_contract.py index 216017947f..9e615fa11b 100644 --- a/src/omnibase_core/models/contracts/model_handler_contract.py +++ b/src/omnibase_core/models/contracts/model_handler_contract.py @@ -20,7 +20,7 @@ >>> contract = ModelHandlerContract( ... handler_id="node.user.reducer", ... name="User Registration Reducer", - ... version="1.0.0", + ... contract_version=ModelSemVer(major=1, minor=0, patch=0), ... descriptor=ModelHandlerBehavior( ... handler_kind="reducer", ... purity="side_effecting", @@ -39,6 +39,8 @@ .. versionadded:: 0.4.1 """ +from __future__ import annotations + from typing import Any from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -51,6 +53,7 @@ ModelExecutionConstraints, ) from omnibase_core.models.errors.model_onex_error import ModelOnexError +from omnibase_core.models.primitives.model_semver import ModelSemVer from omnibase_core.models.runtime.model_handler_behavior import ( ModelHandlerBehavior, ) @@ -70,7 +73,7 @@ class ModelHandlerContract(BaseModel): Identity Fields: - handler_id: Unique identifier for registry lookup - name: Human-readable display name - - version: Semantic version string + - contract_version: Semantic version (ModelSemVer) - description: Optional detailed description Behavior Configuration: @@ -93,7 +96,7 @@ class ModelHandlerContract(BaseModel): Attributes: handler_id: Unique identifier (e.g., "node.user.reducer"). name: Human-readable name (e.g., "User Registration Reducer"). - version: Semantic version string (e.g., "1.0.0"). + contract_version: Semantic version (ModelSemVer instance). description: Optional detailed description. descriptor: Embedded behavior configuration (purity, idempotency, etc.). capability_inputs: List of required input capabilities. @@ -112,7 +115,7 @@ class ModelHandlerContract(BaseModel): >>> contract = ModelHandlerContract( ... handler_id="node.user.reducer", ... name="User Registration Reducer", - ... version="1.0.0", + ... contract_version=ModelSemVer(major=1, minor=0, patch=0), ... descriptor=ModelHandlerBehavior( ... handler_kind="reducer", ... purity="side_effecting", @@ -136,7 +139,7 @@ class ModelHandlerContract(BaseModel): >>> effect_contract = ModelHandlerContract( ... handler_id="handler.email.sender", ... name="Email Sender", - ... version="2.0.0", + ... contract_version=ModelSemVer(major=2, minor=0, patch=0), ... descriptor=ModelHandlerBehavior( ... handler_kind="effect", ... purity="side_effecting", @@ -175,10 +178,9 @@ class ModelHandlerContract(BaseModel): description="Human-readable display name", ) - version: str = Field( + contract_version: ModelSemVer = Field( ..., - pattern=r"^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$", - description="Semantic version string (e.g., '1.0.0', '1.0.0-beta.1')", + description="Semantic version of this handler contract", ) description: str | None = Field( @@ -272,6 +274,31 @@ class ModelHandlerContract(BaseModel): str_strip_whitespace=True, ) + @model_validator(mode="before") + @classmethod + def reject_deprecated_version_field(cls, data: Any) -> Any: + """ + Reject deprecated 'version' field - use 'contract_version' instead. + + Args: + data: Raw input data. + + Returns: + Validated data. + + Raises: + ModelOnexError: If deprecated 'version' field is present. + """ + if isinstance(data, dict) and "version" in data: + raise ModelOnexError( + message=( + "Handler contracts must use 'contract_version', not 'version'. " + "The 'version' field was renamed per ONEX specification (OMN-1436)." + ), + error_code=EnumCoreErrorCode.CONTRACT_VALIDATION_ERROR, + ) + return data + @field_validator("handler_id") @classmethod def validate_handler_id_format(cls, v: str) -> str: @@ -334,7 +361,7 @@ def validate_unique_aliases( return v @model_validator(mode="after") - def validate_descriptor_handler_kind_consistency(self) -> "ModelHandlerContract": + def validate_descriptor_handler_kind_consistency(self) -> ModelHandlerContract: """ Validate that handler_id prefix is consistent with descriptor.handler_kind. diff --git a/src/omnibase_core/models/contracts/model_yaml_contract.py b/src/omnibase_core/models/contracts/model_yaml_contract.py index d9b594303b..c5fe055eee 100644 --- a/src/omnibase_core/models/contracts/model_yaml_contract.py +++ b/src/omnibase_core/models/contracts/model_yaml_contract.py @@ -10,7 +10,9 @@ This replaces manual YAML field validation with proper Pydantic validation. """ -from pydantic import BaseModel, ConfigDict, Field, field_validator +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from omnibase_core.enums import EnumNodeType from omnibase_core.enums.enum_core_error_code import EnumCoreErrorCode @@ -61,6 +63,17 @@ class ModelYamlContract(BaseModel): description="Event subscription patterns for event-driven execution", ) + @model_validator(mode="before") + @classmethod + def reject_deprecated_version_field(cls, data: Any) -> Any: + """Reject deprecated 'version' field - use 'contract_version' instead.""" + if isinstance(data, dict) and "version" in data: + raise ValueError( + "YAML contracts must use 'contract_version', not 'version'. " + "The 'version' field was renamed per ONEX specification (OMN-1431)." + ) + return data + @field_validator("node_type", mode="before") @classmethod def validate_node_type(cls, value: object) -> EnumNodeType: diff --git a/src/omnibase_core/protocols/handler/contracts/protocol_handler_contract.py b/src/omnibase_core/protocols/handler/contracts/protocol_handler_contract.py index 03f7a7685b..14b0eb9e00 100644 --- a/src/omnibase_core/protocols/handler/contracts/protocol_handler_contract.py +++ b/src/omnibase_core/protocols/handler/contracts/protocol_handler_contract.py @@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable if TYPE_CHECKING: + from omnibase_core.models.primitives.model_semver import ModelSemVer from omnibase_core.protocols.handler.contracts.protocol_capability_dependency import ( ProtocolCapabilityDependency, ) @@ -65,7 +66,7 @@ class ProtocolHandlerContract(Protocol): Attributes: handler_id: Unique identifier for this handler. handler_name: Human-readable name for this handler. - handler_version: Semantic version of this handler contract. + contract_version: Semantic version of this handler contract (ModelSemVer). descriptor: Behavior descriptor for this handler. capability_inputs: List of capability dependencies required by this handler. execution_constraints: Execution constraints for this handler. @@ -76,7 +77,7 @@ class ProtocolHandlerContract(Protocol): contract = ProtocolHandlerContract.from_yaml(yaml_content) # Access contract properties - print(f"Handler: {contract.handler_name} v{contract.handler_version}") + print(f"Handler: {contract.handler_name} v{contract.contract_version}") print(f"Idempotent: {contract.descriptor.idempotent}") # Check capability requirements @@ -107,7 +108,7 @@ class ProtocolHandlerContract(Protocol): - memory_limit_mb > 0 (must be positive if specified) - cpu_limit > 0 (must be positive if specified) - concurrency_limit >= 1 (must be at least 1 if specified) - - handler_version matches semver format + - contract_version is a valid ModelSemVer instance These validations ensure contract correctness at construction time rather than deferring to runtime validation. @@ -162,7 +163,7 @@ def handler_name(self) -> str: ... @property - def handler_version(self) -> str: + def contract_version(self) -> ModelSemVer: """ Semantic version of this handler contract. @@ -173,16 +174,16 @@ def handler_version(self) -> str: - PATCH: Bug fixes, backward compatible Version Examples: - - "1.0.0": Initial stable release - - "1.2.3": Minor feature additions with patches - - "2.0.0": Breaking changes from v1 + - ModelSemVer(1, 0, 0): Initial stable release + - ModelSemVer(1, 2, 3): Minor feature additions with patches + - ModelSemVer(2, 0, 0): Breaking changes from v1 Important: Version changes should be coordinated with the handler implementation to ensure contract-implementation compatibility. Returns: - Version string in semver format (e.g., "1.2.3"). + ModelSemVer instance representing the contract version. """ ... @@ -299,7 +300,7 @@ def to_yaml(self) -> str: ```yaml handler_id: "uuid-or-urn" handler_name: "handler-name" - handler_version: "1.0.0" + contract_version: "1.0.0" descriptor: idempotent: true deterministic: false @@ -330,7 +331,7 @@ def from_yaml(cls, content: str) -> Self: Args: content: YAML string to parse. Must contain all required fields - (handler_id, handler_name, handler_version, descriptor). + (handler_id, handler_name, contract_version, descriptor). Optional fields (capability_inputs, execution_constraints) use defaults if not specified. @@ -345,7 +346,7 @@ def from_yaml(cls, content: str) -> Self: yaml_content = ''' handler_id: "http-handler-001" handler_name: "http-rest-handler" - handler_version: "1.0.0" + contract_version: "1.0.0" descriptor: idempotent: true deterministic: false diff --git a/src/omnibase_core/schemas/handler_contract.schema.json b/src/omnibase_core/schemas/handler_contract.schema.json index ae1247b869..75fecd0fd2 100644 --- a/src/omnibase_core/schemas/handler_contract.schema.json +++ b/src/omnibase_core/schemas/handler_contract.schema.json @@ -425,7 +425,7 @@ } }, "additionalProperties": false, - "description": "Complete handler contract - the authoring surface for ONEX handlers.\n\nThe handler contract is the declarative specification that defines:\n- What the handler does (descriptor)\n- What capabilities it needs (capability_inputs)\n- What it provides (capability_outputs)\n- How it fits in execution order (execution_constraints)\n- What it accepts and returns (input_model, output_model)\n\nIdentity Fields:\n - handler_id: Unique identifier for registry lookup\n - name: Human-readable display name\n - version: Semantic version string\n - description: Optional detailed description\n\nBehavior Configuration:\n - descriptor: Embedded ModelHandlerBehavior for runtime semantics\n\nCapability Dependencies:\n - capability_inputs: Required input capabilities (vendor-agnostic)\n - capability_outputs: Provided output capabilities\n\nExecution:\n - input_model: Fully qualified input model reference\n - output_model: Fully qualified output model reference\n - execution_constraints: Ordering constraints (requires_before/after)\n\nLifecycle:\n - supports_lifecycle: Handler implements lifecycle hooks\n - supports_health_check: Handler implements health checking\n - supports_provisioning: Handler can be provisioned/deprovisioned\n\nAttributes:\n handler_id: Unique identifier (e.g., \"node.user.reducer\").\n name: Human-readable name (e.g., \"User Registration Reducer\").\n version: Semantic version string (e.g., \"1.0.0\").\n description: Optional detailed description.\n descriptor: Embedded behavior descriptor (purity, idempotency, etc.).\n capability_inputs: List of required input capabilities.\n capability_outputs: List of provided output capability names.\n input_model: Fully qualified input model reference.\n output_model: Fully qualified output model reference.\n execution_constraints: Ordering constraints for execution.\n supports_lifecycle: Handler implements lifecycle hooks.\n supports_health_check: Handler implements health checking.\n supports_provisioning: Handler supports provisioning.\n tags: Optional tags for categorization and discovery.\n metadata: Optional additional metadata.\n\nExample:\n >>> # Reducer handler contract\n >>> contract = ModelHandlerContract(\n ... handler_id=\"node.user.reducer\",\n ... name=\"User Registration Reducer\",\n ... version=\"1.0.0\",\n ... descriptor=ModelHandlerBehavior(\n ... handler_kind=\"reducer\",\n ... purity=\"side_effecting\",\n ... idempotent=True,\n ... timeout_ms=30000,\n ... ),\n ... capability_inputs=[\n ... ModelCapabilityDependency(\n ... alias=\"db\",\n ... capability=\"database.relational\",\n ... requirements=ModelRequirementSet(\n ... must={\"supports_transactions\": True},\n ... ),\n ... ),\n ... ],\n ... input_model=\"myapp.models.UserRegistrationEvent\",\n ... output_model=\"myapp.models.UserState\",\n ... )\n\n >>> # Effect handler contract\n >>> effect_contract = ModelHandlerContract(\n ... handler_id=\"handler.email.sender\",\n ... name=\"Email Sender\",\n ... version=\"2.0.0\",\n ... descriptor=ModelHandlerBehavior(\n ... handler_kind=\"effect\",\n ... purity=\"side_effecting\",\n ... idempotent=False,\n ... ),\n ... capability_outputs=[\"notification.email\"],\n ... input_model=\"myapp.models.EmailRequest\",\n ... output_model=\"myapp.models.EmailResult\",\n ... supports_health_check=True,\n ... )\n\nThread Safety:\n This model is immutable (frozen=True) and safe for concurrent access.\n\nSee Also:\n - ModelHandlerBehavior: Runtime behavior configuration\n - ModelCapabilityDependency: Capability dependency specification\n - ModelExecutionConstraints: Execution ordering constraints", + "description": "Complete handler contract - the authoring surface for ONEX handlers.\n\nThe handler contract is the declarative specification that defines:\n- What the handler does (descriptor)\n- What capabilities it needs (capability_inputs)\n- What it provides (capability_outputs)\n- How it fits in execution order (execution_constraints)\n- What it accepts and returns (input_model, output_model)\n\nIdentity Fields:\n - handler_id: Unique identifier for registry lookup\n - name: Human-readable display name\n - contract_version: Semantic version object with major/minor/patch\n - description: Optional detailed description\n\nBehavior Configuration:\n - descriptor: Embedded ModelHandlerBehavior for runtime semantics\n\nCapability Dependencies:\n - capability_inputs: Required input capabilities (vendor-agnostic)\n - capability_outputs: Provided output capabilities\n\nExecution:\n - input_model: Fully qualified input model reference\n - output_model: Fully qualified output model reference\n - execution_constraints: Ordering constraints (requires_before/after)\n\nLifecycle:\n - supports_lifecycle: Handler implements lifecycle hooks\n - supports_health_check: Handler implements health checking\n - supports_provisioning: Handler can be provisioned/deprovisioned\n\nAttributes:\n handler_id: Unique identifier (e.g., \"node.user.reducer\").\n name: Human-readable name (e.g., \"User Registration Reducer\").\n contract_version: Semantic version object (e.g., {major: 1, minor: 0, patch: 0}).\n description: Optional detailed description.\n descriptor: Embedded behavior descriptor (purity, idempotency, etc.).\n capability_inputs: List of required input capabilities.\n capability_outputs: List of provided output capability names.\n input_model: Fully qualified input model reference.\n output_model: Fully qualified output model reference.\n execution_constraints: Ordering constraints for execution.\n supports_lifecycle: Handler implements lifecycle hooks.\n supports_health_check: Handler implements health checking.\n supports_provisioning: Handler supports provisioning.\n tags: Optional tags for categorization and discovery.\n metadata: Optional additional metadata.\n\nExample:\n >>> # Reducer handler contract\n >>> contract = ModelHandlerContract(\n ... handler_id=\"node.user.reducer\",\n ... name=\"User Registration Reducer\",\n ... contract_version={\"major\": 1, \"minor\": 0, \"patch\": 0},\n ... descriptor=ModelHandlerBehavior(\n ... handler_kind=\"reducer\",\n ... purity=\"side_effecting\",\n ... idempotent=True,\n ... timeout_ms=30000,\n ... ),\n ... capability_inputs=[\n ... ModelCapabilityDependency(\n ... alias=\"db\",\n ... capability=\"database.relational\",\n ... requirements=ModelRequirementSet(\n ... must={\"supports_transactions\": True},\n ... ),\n ... ),\n ... ],\n ... input_model=\"myapp.models.UserRegistrationEvent\",\n ... output_model=\"myapp.models.UserState\",\n ... )\n\n >>> # Effect handler contract\n >>> effect_contract = ModelHandlerContract(\n ... handler_id=\"handler.email.sender\",\n ... name=\"Email Sender\",\n ... contract_version={\"major\": 2, \"minor\": 0, \"patch\": 0},\n ... descriptor=ModelHandlerBehavior(\n ... handler_kind=\"effect\",\n ... purity=\"side_effecting\",\n ... idempotent=False,\n ... ),\n ... capability_outputs=[\"notification.email\"],\n ... input_model=\"myapp.models.EmailRequest\",\n ... output_model=\"myapp.models.EmailResult\",\n ... supports_health_check=True,\n ... )\n\nThread Safety:\n This model is immutable (frozen=True) and safe for concurrent access.\n\nSee Also:\n - ModelHandlerBehavior: Runtime behavior configuration\n - ModelCapabilityDependency: Capability dependency specification\n - ModelExecutionConstraints: Execution ordering constraints", "properties": { "handler_id": { "description": "Unique identifier for registry lookup (e.g., 'node.user.reducer')", @@ -441,11 +441,60 @@ "title": "Name", "type": "string" }, - "version": { - "description": "Semantic version string (e.g., '1.0.0', '1.0.0-beta.1')", - "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?(\\+[a-zA-Z0-9.]+)?$", - "title": "Version", - "type": "string" + "contract_version": { + "type": "object", + "description": "Semantic version of this handler contract", + "properties": { + "major": { + "type": "integer", + "minimum": 0, + "description": "Major version number" + }, + "minor": { + "type": "integer", + "minimum": 0, + "description": "Minor version number" + }, + "patch": { + "type": "integer", + "minimum": 0, + "description": "Patch version number" + }, + "prerelease": { + "anyOf": [ + { + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + {"type": "string"}, + {"type": "integer"} + ] + }, + "description": "Array of prerelease identifiers (strings or integers)" + }, + {"type": "null"} + ], + "default": null, + "description": "Pre-release identifiers (e.g., ['alpha', 1] for 'alpha.1')" + }, + "build": { + "anyOf": [ + { + "type": "array", + "minItems": 1, + "items": {"type": "string"}, + "description": "Array of build metadata identifiers" + }, + {"type": "null"} + ], + "default": null, + "description": "Build metadata identifiers (e.g., ['build', '123'] for 'build.123')" + } + }, + "required": ["major", "minor", "patch"], + "additionalProperties": false, + "title": "Contract Version" }, "description": { "anyOf": [ @@ -541,7 +590,7 @@ "required": [ "handler_id", "name", - "version", + "contract_version", "descriptor", "input_model", "output_model" diff --git a/src/omnibase_core/utils/util_contract_loader.py b/src/omnibase_core/utils/util_contract_loader.py index 6813b85ea6..f2187249e6 100644 --- a/src/omnibase_core/utils/util_contract_loader.py +++ b/src/omnibase_core/utils/util_contract_loader.py @@ -277,6 +277,20 @@ def _parse_contract_content( ModelContractContent: Parsed contract content """ try: + # Fail fast on deprecated 'version' field (OMN-1431) + # Contracts must use 'contract_version' per ONEX specification + if "version" in raw_content: + raise ModelOnexError( + message="Contract uses deprecated 'version' field. Rename to 'contract_version' per ONEX specification (OMN-1431).", + error_code=EnumCoreErrorCode.CONTRACT_VALIDATION_ERROR, + context={ + "contract_path": str(contract_path), + "field": "version", + "expected": "contract_version", + "ticket": "OMN-1431", + }, + ) + # Parse contract version version_data = raw_content.get("contract_version", {}) if not isinstance(version_data, dict): diff --git a/src/omnibase_core/validation/phases/validator_expanded_contract.py b/src/omnibase_core/validation/phases/validator_expanded_contract.py index 62f377b864..e49ba35a2b 100644 --- a/src/omnibase_core/validation/phases/validator_expanded_contract.py +++ b/src/omnibase_core/validation/phases/validator_expanded_contract.py @@ -101,7 +101,7 @@ class ExpandedContractValidator: # naming-ok: validator class, not protocol ensures alias uniqueness. 4. **Runtime Invariants**: Validates handler_id format, input/output model - references, version format, and handler_kind consistency. + references, contract_version format, and handler_kind consistency. Validation is performed in a single pass for efficiency. All validations are deterministic and do not require external resources. @@ -176,7 +176,7 @@ def validate(self, contract: ModelHandlerContract) -> ModelValidationResult[None f"Starting expanded contract validation for handler_id={contract.handler_id}" ) - # 1. Runtime invariant checks (handler_id, models, version) + # 1. Runtime invariant checks (handler_id, models, contract_version) self._validate_runtime_invariants(contract, result) # 2. Execution graph integrity (cycles and orphans) @@ -221,7 +221,7 @@ def _validate_runtime_invariants( Checks: - handler_id format (dot-separated, starts with letter/underscore) - input_model and output_model look like valid module paths - - version follows semver pattern + - contract_version follows semver pattern Args: contract: The contract to validate. @@ -239,7 +239,8 @@ def _validate_runtime_invariants( self._validate_model_reference(contract.output_model, "output_model", result) # Validate version format - self._validate_version_format(contract.version, result) + # Note: contract_version is ModelSemVer, convert to string for format validation + self._validate_version_format(str(contract.contract_version), result) def _validate_handler_id_format( self, diff --git a/src/omnibase_core/validation/phases/validator_merge.py b/src/omnibase_core/validation/phases/validator_merge.py index dd6967cd24..27a428c782 100644 --- a/src/omnibase_core/validation/phases/validator_merge.py +++ b/src/omnibase_core/validation/phases/validator_merge.py @@ -116,7 +116,7 @@ { "handler_id", "name", - "version", + "contract_version", "input_model", "output_model", } @@ -282,7 +282,7 @@ def _validate_placeholder_values( # stub-ok: docstring describes detection patt Critical fields checked: - handler_id - name - - version + - contract_version - input_model - output_model @@ -293,10 +293,11 @@ def _validate_placeholder_values( # stub-ok: docstring describes detection patt logger.debug("Checking for placeholder values in critical fields") # Build field->value mapping for critical fields + # Note: contract_version is ModelSemVer, convert to string for placeholder check field_values: dict[str, str] = { "handler_id": merged.handler_id, "name": merged.name, - "version": merged.version, + "contract_version": str(merged.contract_version), "input_model": merged.input_model, "output_model": merged.output_model, } diff --git a/tests/integration/validation/test_contract_validation_events_e2e.py b/tests/integration/validation/test_contract_validation_events_e2e.py index d1ec86b713..44f946628f 100644 --- a/tests/integration/validation/test_contract_validation_events_e2e.py +++ b/tests/integration/validation/test_contract_validation_events_e2e.py @@ -139,7 +139,7 @@ def valid_merged_contract( return ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), description="Test handler description", descriptor=valid_descriptor, input_model="omnibase_core.models.events.ModelTestEvent", diff --git a/tests/unit/cli/test_cli_contract.py b/tests/unit/cli/test_cli_contract.py index 9b927a0e5f..22e1169bfb 100644 --- a/tests/unit/cli/test_cli_contract.py +++ b/tests/unit/cli/test_cli_contract.py @@ -481,7 +481,7 @@ def test_build_produces_expanded_contract( # Should have expanded contract fields assert "handler_id" in content assert "name" in content - assert "version" in content + assert "contract_version" in content assert "descriptor" in content def test_build_output_includes_metadata( diff --git a/tests/unit/contracts/test_hash_registry.py b/tests/unit/contracts/test_hash_registry.py index db4d1e7396..e4568f02f7 100644 --- a/tests/unit/contracts/test_hash_registry.py +++ b/tests/unit/contracts/test_hash_registry.py @@ -46,12 +46,12 @@ class ModelTestContract(BaseModel): """Simple test contract model for fingerprinting tests. - Note: version field accepts both string ("1.0.0") and ModelContractVersion + Note: contract_version field accepts both string ("1.0.0") and ModelContractVersion for test convenience, but is stored as ModelContractVersion. """ name: str = Field(default="test_contract") - version: ModelContractVersion = Field( + contract_version: ModelContractVersion = Field( default_factory=lambda: ModelContractVersion(major=1, minor=0, patch=0) ) description: str | None = Field(default=None) @@ -62,7 +62,7 @@ class ModelTestContract(BaseModel): nested: dict[str, object] | None = Field(default=None) list_with_nulls: list[int | None] | None = Field(default=None) - @field_validator("version", mode="before") + @field_validator("contract_version", mode="before") @classmethod def convert_version_string(cls, v: object) -> ModelContractVersion: """Convert string versions to ModelContractVersion for test convenience.""" @@ -85,7 +85,7 @@ def sample_contract() -> ModelTestContract: """Sample contract for testing.""" return ModelTestContract( name="test_contract", - version="1.2.3", + contract_version="1.2.3", description="A test contract", node_type="COMPUTE_GENERIC", input_model="omnibase_core.models.ModelInput", @@ -98,7 +98,7 @@ def sample_contract_with_nulls() -> ModelTestContract: """Sample contract with null values for testing null removal.""" return ModelTestContract( name="test_contract", - version="1.0.0", + contract_version="1.0.0", description=None, optional_field=None, nested={ @@ -174,7 +174,7 @@ def test_normalize_sorts_keys_alphabetically(self) -> None: """ contract = ModelTestContract( name="test", - version="1.0.0", + contract_version="1.0.0", nested={"zebra": 1, "apple": 2, "mango": 3}, ) result = normalize_contract(contract) @@ -188,7 +188,7 @@ def test_normalize_sorts_nested_keys(self) -> None: """Test that deeply nested keys are also sorted.""" contract = ModelTestContract( name="test", - version="1.0.0", + contract_version="1.0.0", nested={ "outer": {"zebra": 1, "apple": 2}, "another": {"delta": 3, "alpha": 4}, @@ -306,7 +306,7 @@ def test_compute_fingerprint_format( def test_compute_fingerprint_default_version(self) -> None: """Test that contracts without version attribute use default.""" - # ModelTestContract has version="1.0.0" by default + # ModelTestContract has contract_version="1.0.0" by default contract = ModelTestContract(name="test") fingerprint = compute_contract_fingerprint(contract) @@ -316,7 +316,7 @@ def test_compute_fingerprint_default_version(self) -> None: def test_compute_fingerprint_with_model_version(self) -> None: """Test fingerprint with ModelContractVersion object.""" version = ModelContractVersion(major=3, minor=0, patch=0) - contract = ModelTestContract(name="test", version=version) + contract = ModelTestContract(name="test", contract_version=version) fingerprint = compute_contract_fingerprint(contract) assert fingerprint.version == version @@ -335,8 +335,8 @@ def test_compute_fingerprint_deterministic( def test_compute_fingerprint_different_content_different_hash(self) -> None: """Test that different content produces different hashes.""" - contract1 = ModelTestContract(name="test1", version="1.0.0") - contract2 = ModelTestContract(name="test2", version="1.0.0") + contract1 = ModelTestContract(name="test1", contract_version="1.0.0") + contract2 = ModelTestContract(name="test2", contract_version="1.0.0") fp1 = compute_contract_fingerprint(contract1) fp2 = compute_contract_fingerprint(contract2) @@ -440,7 +440,7 @@ class CustomVersionModel(BaseModel): custom_metadata: str = "custom" class ContractWithCustomVersion(BaseModel): - version: CustomVersionModel = Field( + contract_version: CustomVersionModel = Field( default_factory=lambda: CustomVersionModel(major=2, minor=5, patch=1) ) node_type: str = "COMPUTE_GENERIC" @@ -914,7 +914,7 @@ def test_same_contract_same_fingerprint(self) -> None: """ contract = ModelTestContract( name="stability_test", - version="1.0.0", + contract_version="1.0.0", description="Testing stability", ) @@ -932,12 +932,12 @@ def test_key_order_does_not_affect_fingerprint(self) -> None: """ contract1 = ModelTestContract( name="test", - version="1.0.0", + contract_version="1.0.0", nested={"zebra": 1, "apple": 2, "mango": 3}, ) contract2 = ModelTestContract( name="test", - version="1.0.0", + contract_version="1.0.0", nested={"apple": 2, "mango": 3, "zebra": 1}, ) @@ -948,9 +948,11 @@ def test_key_order_does_not_affect_fingerprint(self) -> None: def test_null_presence_normalized(self) -> None: """Test that null values are normalized consistently.""" - contract1 = ModelTestContract(name="test", version="1.0.0", optional_field=None) + contract1 = ModelTestContract( + name="test", contract_version="1.0.0", optional_field=None + ) contract2 = ModelTestContract( - name="test", version="1.0.0" + name="test", contract_version="1.0.0" ) # optional_field defaults to None fp1 = compute_contract_fingerprint(contract1) @@ -963,12 +965,12 @@ def test_nested_null_removal_consistent(self) -> None: """Test that nested null removal is consistent.""" contract1 = ModelTestContract( name="test", - version="1.0.0", + contract_version="1.0.0", nested={"keep": "value", "remove": None}, ) contract2 = ModelTestContract( name="test", - version="1.0.0", + contract_version="1.0.0", nested={"keep": "value"}, ) @@ -981,7 +983,7 @@ def test_whitespace_normalization(self) -> None: """Test that whitespace in values is preserved but format is normalized.""" contract = ModelTestContract( name=" test ", # Whitespace in value - version="1.0.0", + contract_version="1.0.0", description="line1\nline2", # Newline in value ) @@ -1011,7 +1013,7 @@ def test_normalize_non_serializable_raises_error(self) -> None: # Create contract with non-serializable nested content contract = ModelTestContract( name="test", - version="1.0.0", + contract_version="1.0.0", nested={"callback": lambda x: x}, # Lambda not JSON-serializable ) @@ -1025,7 +1027,7 @@ def test_large_contract_normalization(self) -> None: # Create a large contract with deeply nested data large_contract = ModelTestContract( name="large_contract", - version="1.0.0", + contract_version="1.0.0", nested={f"key_{i}": f"value_{i}" for i in range(100)}, ) @@ -1037,7 +1039,7 @@ def test_special_characters_in_values(self) -> None: """Test handling of special characters in values.""" contract = ModelTestContract( name="test", - version="1.0.0", + contract_version="1.0.0", description='Contains "quotes" and \\backslashes\\ and \t tabs', ) @@ -1053,7 +1055,7 @@ def test_empty_lists_preserved(self) -> None: """Test that empty lists are preserved in normalization.""" contract = ModelTestContract( name="test", - version="1.0.0", + contract_version="1.0.0", list_with_nulls=[], # Empty list ) @@ -1067,7 +1069,7 @@ def test_empty_nested_dicts_removed_after_null_removal(self) -> None: """Test that empty dicts after null removal are also removed.""" contract = ModelTestContract( name="test", - version="1.0.0", + contract_version="1.0.0", nested={"only_null": None}, # Will become empty after null removal ) @@ -1131,7 +1133,7 @@ def test_same_contract_different_sources_same_fingerprint(self) -> None: # Simulate loading from YAML (with some null fields) yaml_loaded = ModelTestContract( name="my_service", - version="1.0.0", + contract_version="1.0.0", description="Service contract", optional_field=None, node_type="COMPUTE_GENERIC", @@ -1140,7 +1142,7 @@ def test_same_contract_different_sources_same_fingerprint(self) -> None: # Simulate loading from Python definition (no null fields explicitly set) python_defined = ModelTestContract( name="my_service", - version="1.0.0", + contract_version="1.0.0", description="Service contract", node_type="COMPUTE_GENERIC", ) @@ -1156,11 +1158,11 @@ def test_version_bump_detected_as_drift(self) -> None: registry = ContractHashRegistry() # Register v1.0.0 - v1_contract = ModelTestContract(name="my_service", version="1.0.0") + v1_contract = ModelTestContract(name="my_service", contract_version="1.0.0") registry.register_from_contract("my_service", v1_contract) # Check v1.1.0 with same content - v1_1_contract = ModelTestContract(name="my_service", version="1.1.0") + v1_1_contract = ModelTestContract(name="my_service", contract_version="1.1.0") result = registry.detect_drift_from_contract("my_service", v1_1_contract) @@ -1172,12 +1174,12 @@ def test_content_change_detected_as_drift(self) -> None: registry = ContractHashRegistry() # Register original - original = ModelTestContract(name="my_service", version="1.0.0") + original = ModelTestContract(name="my_service", contract_version="1.0.0") registry.register_from_contract("my_service", original) # Check modified (same version, different content via description change) modified = ModelTestContract( - name="my_service", version="1.0.0", description="Added description" + name="my_service", contract_version="1.0.0", description="Added description" ) result = registry.detect_drift_from_contract("my_service", modified) @@ -1625,7 +1627,7 @@ def test_large_contract_performance(self) -> None: # Create a large contract with many nested fields large_contract = ModelTestContract( name="large_contract", - version="1.0.0", + contract_version="1.0.0", description="A contract with many fields for performance testing", nested={ f"level1_{i}": {f"level2_{j}": f"value_{i}_{j}" for j in range(10)} @@ -1744,7 +1746,7 @@ def test_memory_baseline(self) -> None: # Create a sample fingerprint fingerprint = compute_contract_fingerprint( - ModelTestContract(name="test", version="1.0.0") + ModelTestContract(name="test", contract_version="1.0.0") ) # Get approximate size diff --git a/tests/unit/examples/demo/handlers/support_assistant/test_handler_contract.py b/tests/unit/examples/demo/handlers/support_assistant/test_handler_contract.py index ba7d570595..b8a50b98a9 100644 --- a/tests/unit/examples/demo/handlers/support_assistant/test_handler_contract.py +++ b/tests/unit/examples/demo/handlers/support_assistant/test_handler_contract.py @@ -58,7 +58,7 @@ def test_handler_contract_loads(self, contract_path: Path) -> None: # Verify essential fields are present assert contract.handler_id is not None assert contract.name is not None - assert contract.version is not None + assert contract.contract_version is not None assert contract.descriptor is not None def test_handler_id_matches_kind(self, contract: ModelHandlerContract) -> None: @@ -185,10 +185,9 @@ def test_lifecycle_support(self, contract: ModelHandlerContract) -> None: def test_version_is_semver(self, contract: ModelHandlerContract) -> None: """Version follows semantic versioning.""" - # Version pattern is validated by the model, but verify format - assert contract.version == "1.0.0" - parts = contract.version.split(".") - assert len(parts) >= 3 - # First three parts should be numeric - for part in parts[:3]: - assert part.isdigit() + # contract_version is ModelSemVer, verify format + assert str(contract.contract_version) == "1.0.0" + # ModelSemVer has major, minor, patch as integers + assert contract.contract_version.major == 1 + assert contract.contract_version.minor == 0 + assert contract.contract_version.patch == 0 diff --git a/tests/unit/merge/test_contract_merge_engine.py b/tests/unit/merge/test_contract_merge_engine.py index 5a7b115c53..180f26c516 100644 --- a/tests/unit/merge/test_contract_merge_engine.py +++ b/tests/unit/merge/test_contract_merge_engine.py @@ -364,7 +364,7 @@ def test_version_override_with_model_semver( # Patch has node_version=ModelSemVer(1, 0, 0) # The merge engine converts version to string representation - assert result.version == "1.0.0" + assert str(result.contract_version) == "1.0.0" def test_none_patch_fields_use_base_values( self, @@ -499,7 +499,7 @@ def test_handlers_add( # Verify merge completed successfully with valid contract structure assert result is not None assert result.name == "test_contract" - assert result.version == "1.0.0" + assert str(result.contract_version) == "1.0.0" assert hasattr(result, "descriptor") assert hasattr(result, "capability_inputs") # Verify the patch handler_add was set correctly @@ -540,7 +540,7 @@ def test_handlers_remove( # Verify merge completed successfully with valid contract structure assert result is not None assert result.name == "test_contract" - assert result.version == "1.0.0" + assert str(result.contract_version) == "1.0.0" # Verify the patch handlers__remove was set correctly assert patch.handlers__remove is not None assert "handler_to_remove" in patch.handlers__remove @@ -564,7 +564,7 @@ def test_handlers_add_and_remove( # Verify merge completed successfully with expected identity assert result is not None assert result.name == "handler_contract" - assert result.version == "1.0.0" + assert str(result.contract_version) == "1.0.0" # Verify the patch had both add and remove operations assert patch_with_handlers.handlers__add is not None assert patch_with_handlers.handlers__remove is not None @@ -590,7 +590,7 @@ def test_dependencies_add( # Verify merge completed successfully with expected identity assert result is not None assert result.name == "dependency_contract" - assert result.version == "1.0.0" + assert str(result.contract_version) == "1.0.0" # Verify the patch had dependency operations assert patch_with_dependencies.dependencies__add is not None assert patch_with_dependencies.dependencies__remove is not None @@ -616,7 +616,7 @@ def test_consumed_events_add( # Verify merge completed successfully with expected identity assert result is not None assert result.name == "event_contract" - assert result.version == "1.0.0" + assert str(result.contract_version) == "1.0.0" # Verify the patch had event operations assert patch_with_events.consumed_events__add is not None assert patch_with_events.consumed_events__remove is not None @@ -642,7 +642,7 @@ def test_capability_inputs_add( # Verify merge completed successfully assert result is not None assert result.name == "capability_contract" - assert result.version == "1.0.0" + assert str(result.contract_version) == "1.0.0" # Verify capability_inputs contains the added item # The merge engine converts string names to ModelCapabilityDependency @@ -676,7 +676,7 @@ def test_capability_outputs_add( # Verify merge completed successfully assert result is not None assert result.name == "capability_contract" - assert result.version == "1.0.0" + assert str(result.contract_version) == "1.0.0" # Verify capability_outputs contains the added capability name # The patch adds sample_capability_provided which has name="event_emit" @@ -714,7 +714,7 @@ def test_empty_add_list_is_noop( # Verify merge completed with expected contract identity assert result is not None assert result.name == "test_contract" - assert result.version == "1.0.0" + assert str(result.contract_version) == "1.0.0" # Verify capability_inputs is empty (no additions, base was empty) assert result.capability_inputs == [] # Verify capability_outputs is empty (no additions, base was empty) @@ -926,8 +926,8 @@ def test_new_contract_requires_name_and_version( result = engine.merge(new_contract_patch) assert result.name == new_contract_patch.name - # Version is a string in ModelHandlerContract, compare as string - assert result.version == str(new_contract_patch.node_version) + # contract_version is ModelSemVer in ModelHandlerContract, compare directly + assert result.contract_version == new_contract_patch.node_version def test_override_only_patch( self, @@ -1000,7 +1000,7 @@ def test_merge_returns_contract( # Result should have standard contract fields assert hasattr(result, "name") - assert hasattr(result, "version") + assert hasattr(result, "contract_version") assert hasattr(result, "description") def test_merge_result_has_handler_kind( diff --git a/tests/unit/models/contracts/test_handler_contract_examples.py b/tests/unit/models/contracts/test_handler_contract_examples.py index aedcb30ff5..3d36748fba 100644 --- a/tests/unit/models/contracts/test_handler_contract_examples.py +++ b/tests/unit/models/contracts/test_handler_contract_examples.py @@ -43,7 +43,7 @@ def test_effect_handler_example_loads(self, examples_dir: Path) -> None: # Validate key fields are populated correctly assert contract.handler_id == "effect.database.user_repository" assert contract.name == "User Repository" - assert contract.version == "2.0.0" + assert str(contract.contract_version) == "2.0.0" assert contract.descriptor.handler_kind == "effect" assert contract.descriptor.purity == "side_effecting" assert contract.descriptor.idempotent is True @@ -91,7 +91,7 @@ def test_reducer_handler_example_loads(self, examples_dir: Path) -> None: # Validate key fields are populated correctly assert contract.handler_id == "reducer.registration.user" assert contract.name == "User Registration Reducer" - assert contract.version == "1.2.0" + assert str(contract.contract_version) == "1.2.0" assert contract.descriptor.handler_kind == "reducer" assert contract.descriptor.purity == "side_effecting" assert contract.descriptor.idempotent is True @@ -132,7 +132,7 @@ def test_compute_handler_example_loads(self, examples_dir: Path) -> None: # Validate key fields are populated correctly assert contract.handler_id == "compute.schema.validator" assert contract.name == "Schema Validator" - assert contract.version == "1.0.0" + assert str(contract.contract_version) == "1.0.0" assert contract.descriptor.handler_kind == "compute" assert contract.descriptor.purity == "pure" assert contract.descriptor.idempotent is True @@ -172,7 +172,9 @@ def test_all_example_files_load_successfully(self, examples_dir: Path) -> None: # Basic sanity checks for any handler contract assert contract.handler_id, f"handler_id missing in {yaml_path.name}" assert contract.name, f"name missing in {yaml_path.name}" - assert contract.version, f"version missing in {yaml_path.name}" + assert contract.contract_version, ( + f"contract_version missing in {yaml_path.name}" + ) assert contract.descriptor, f"descriptor missing in {yaml_path.name}" assert contract.input_model, f"input_model missing in {yaml_path.name}" assert contract.output_model, f"output_model missing in {yaml_path.name}" diff --git a/tests/unit/models/contracts/test_model_handler_contract.py b/tests/unit/models/contracts/test_model_handler_contract.py index 546f127f10..126faba0a9 100644 --- a/tests/unit/models/contracts/test_model_handler_contract.py +++ b/tests/unit/models/contracts/test_model_handler_contract.py @@ -12,6 +12,8 @@ - Helper methods """ +from __future__ import annotations + import pytest from pydantic import ValidationError @@ -26,6 +28,7 @@ ) from omnibase_core.models.contracts.model_handler_contract import ModelHandlerContract from omnibase_core.models.errors.model_onex_error import ModelOnexError +from omnibase_core.models.primitives.model_semver import ModelSemVer from omnibase_core.models.runtime.model_handler_behavior import ( ModelHandlerBehavior, ) @@ -40,14 +43,14 @@ def test_minimal_creation(self) -> None: contract = ModelHandlerContract( handler_id="node.test.handler", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="myapp.models.Input", output_model="myapp.models.Output", ) assert contract.handler_id == "node.test.handler" assert contract.name == "Test Handler" - assert contract.version == "1.0.0" + assert str(contract.contract_version) == "1.0.0" assert contract.descriptor.handler_kind == "compute" def test_full_creation(self) -> None: @@ -55,7 +58,9 @@ def test_full_creation(self) -> None: contract = ModelHandlerContract( handler_id="reducer.user.registration", name="User Registration Reducer", - version="2.1.0-beta.1", + contract_version=ModelSemVer( + major=2, minor=1, patch=0, prerelease=("beta", "1") + ), description="Handles user registration lifecycle", descriptor=ModelHandlerBehavior( handler_kind="reducer", @@ -101,7 +106,7 @@ def test_valid_two_segment_id(self) -> None: contract = ModelHandlerContract( handler_id="node.handler", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="a.Input", output_model="a.Output", @@ -113,7 +118,7 @@ def test_valid_multi_segment_id(self) -> None: contract = ModelHandlerContract( handler_id="effect.database.user.repository", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="effect"), input_model="a.Input", output_model="a.Output", @@ -126,7 +131,7 @@ def test_single_segment_id_rejected(self) -> None: ModelHandlerContract( handler_id="handler", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="a.Input", output_model="a.Output", @@ -138,7 +143,7 @@ def test_empty_segment_id_rejected(self) -> None: ModelHandlerContract( handler_id="node..handler", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="a.Input", output_model="a.Output", @@ -149,7 +154,7 @@ def test_underscore_prefix_allowed(self) -> None: contract = ModelHandlerContract( handler_id="node._internal.handler", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="a.Input", output_model="a.Output", @@ -159,51 +164,50 @@ def test_underscore_prefix_allowed(self) -> None: @pytest.mark.unit class TestVersionValidation: - """Tests for semantic version format validation.""" + """Tests for contract_version field validation.""" @pytest.mark.parametrize( - "version", + ("major", "minor", "patch", "prerelease", "build"), [ - "1.0.0", - "0.0.1", - "10.20.30", - "1.0.0-alpha", - "1.0.0-beta.1", - "1.0.0-rc.1", - "1.0.0+build.123", - "1.0.0-beta.1+build.456", + (1, 0, 0, None, None), + (0, 0, 1, None, None), + (10, 20, 30, None, None), + (1, 0, 0, ("alpha",), None), + (1, 0, 0, ("beta", "1"), None), + (1, 0, 0, ("rc", "1"), None), + (1, 0, 0, None, ("build", "123")), + (1, 0, 0, ("beta", "1"), ("build", "456")), ], ) - def test_valid_versions(self, version: str) -> None: - """Test various valid semantic version formats.""" + def test_valid_semver_accepted( + self, + major: int, + minor: int, + patch: int, + prerelease: tuple[str, ...] | None, + build: tuple[str, ...] | None, + ) -> None: + """Test various valid ModelSemVer configurations are accepted.""" + version = ModelSemVer( + major=major, minor=minor, patch=patch, prerelease=prerelease, build=build + ) contract = ModelHandlerContract( handler_id="node.test", name="Test", - version=version, + contract_version=version, descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="a.Input", output_model="a.Output", ) - assert contract.version == version + assert contract.contract_version == version - @pytest.mark.parametrize( - "version", - [ - "1.0", - "1", - "v1.0.0", - "1.0.0.0", - "latest", - "1.0.0-", - ], - ) - def test_invalid_versions_rejected(self, version: str) -> None: - """Test various invalid version formats are rejected.""" - with pytest.raises(ValidationError): + def test_deprecated_version_field_rejected(self) -> None: + """Test that deprecated 'version' string field is rejected.""" + with pytest.raises(ModelOnexError, match="must use 'contract_version'"): ModelHandlerContract( handler_id="node.test", name="Test", - version=version, + version="1.0.0", # type: ignore[call-arg] descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="a.Input", output_model="a.Output", @@ -219,7 +223,7 @@ def test_compute_prefix_matches_compute_kind(self) -> None: contract = ModelHandlerContract( handler_id="compute.data.transformer", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="a.Input", output_model="a.Output", @@ -231,7 +235,7 @@ def test_effect_prefix_matches_effect_kind(self) -> None: contract = ModelHandlerContract( handler_id="effect.database.writer", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="effect"), input_model="a.Input", output_model="a.Output", @@ -243,7 +247,7 @@ def test_reducer_prefix_matches_reducer_kind(self) -> None: contract = ModelHandlerContract( handler_id="reducer.user.state", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="reducer"), input_model="a.Input", output_model="a.Output", @@ -256,7 +260,7 @@ def test_generic_node_prefix_accepts_any_kind(self) -> None: contract = ModelHandlerContract( handler_id="node.test.handler", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind=kind), # type: ignore[arg-type] input_model="a.Input", output_model="a.Output", @@ -269,7 +273,7 @@ def test_generic_handler_prefix_accepts_any_kind(self) -> None: contract = ModelHandlerContract( handler_id="handler.test.impl", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind=kind), # type: ignore[arg-type] input_model="a.Input", output_model="a.Output", @@ -282,7 +286,7 @@ def test_compute_prefix_rejects_effect_kind(self) -> None: ModelHandlerContract( handler_id="compute.data.transformer", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="effect"), input_model="a.Input", output_model="a.Output", @@ -299,7 +303,7 @@ def test_unique_aliases_required(self) -> None: ModelHandlerContract( handler_id="node.test", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="effect"), capability_inputs=[ ModelCapabilityDependency(alias="db", capability="database"), @@ -314,7 +318,7 @@ def test_get_capability_aliases(self) -> None: contract = ModelHandlerContract( handler_id="node.test", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="effect"), capability_inputs=[ ModelCapabilityDependency(alias="db", capability="database"), @@ -331,7 +335,7 @@ def test_get_required_capabilities(self) -> None: contract = ModelHandlerContract( handler_id="node.test", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="effect"), capability_inputs=[ ModelCapabilityDependency( @@ -352,7 +356,7 @@ def test_get_optional_capabilities(self) -> None: contract = ModelHandlerContract( handler_id="node.test", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="effect"), capability_inputs=[ ModelCapabilityDependency( @@ -378,7 +382,7 @@ def test_has_execution_constraints_none(self) -> None: contract = ModelHandlerContract( handler_id="node.test", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="a.Input", output_model="a.Output", @@ -390,7 +394,7 @@ def test_has_execution_constraints_empty(self) -> None: contract = ModelHandlerContract( handler_id="node.test", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), execution_constraints=ModelExecutionConstraints(), input_model="a.Input", @@ -403,7 +407,7 @@ def test_has_execution_constraints_with_ordering(self) -> None: contract = ModelHandlerContract( handler_id="node.test", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), execution_constraints=ModelExecutionConstraints( requires_before=["capability:auth"], @@ -423,7 +427,7 @@ def test_model_is_frozen(self) -> None: contract = ModelHandlerContract( handler_id="node.test", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="a.Input", output_model="a.Output", @@ -437,7 +441,7 @@ def test_extra_fields_forbidden(self) -> None: ModelHandlerContract( handler_id="node.test", name="Test", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="a.Input", output_model="a.Output", diff --git a/tests/unit/orchestrator/test_contract_versioning.py b/tests/unit/orchestrator/test_contract_versioning.py index da3c333cf2..d5f1d4af17 100644 --- a/tests/unit/orchestrator/test_contract_versioning.py +++ b/tests/unit/orchestrator/test_contract_versioning.py @@ -63,7 +63,7 @@ class ModelTestOrchestratorContract(BaseModel): default="test_orchestrator", description="Contract name for identification", ) - version: ModelContractVersion = Field( + contract_version: ModelContractVersion = Field( default_factory=lambda: ModelContractVersion(major=1, minor=0, patch=0), description="Semantic version of the contract", ) @@ -130,7 +130,7 @@ class ModelTestOrchestratorContract(BaseModel): description="Contract classification tags", ) - @field_validator("version", mode="before") + @field_validator("contract_version", mode="before") @classmethod def convert_version_string(cls, v: object) -> ModelContractVersion: """Convert string versions to ModelContractVersion for test convenience.""" @@ -152,11 +152,11 @@ class ModelMinimalContract(BaseModel): """Minimal contract model for version field testing.""" name: str = Field(default="minimal") - version: ModelContractVersion = Field( + contract_version: ModelContractVersion = Field( default_factory=lambda: ModelContractVersion(major=0, minor=0, patch=0) ) - @field_validator("version", mode="before") + @field_validator("contract_version", mode="before") @classmethod def convert_version(cls, v: object) -> ModelContractVersion: """Convert string versions to ModelContractVersion.""" @@ -179,7 +179,7 @@ def orchestrator_contract() -> ModelTestOrchestratorContract: """Standard orchestrator contract for testing.""" return ModelTestOrchestratorContract( name="workflow_orchestrator", - version="1.0.0", + contract_version="1.0.0", description="Workflow coordination orchestrator", node_type="ORCHESTRATOR_GENERIC", execution_mode="parallel", @@ -195,7 +195,7 @@ def orchestrator_contract_with_nested() -> ModelTestOrchestratorContract: """Orchestrator contract with nested workflow configuration.""" return ModelTestOrchestratorContract( name="complex_orchestrator", - version="2.0.0", + contract_version="2.0.0", description="Complex workflow orchestrator", workflow_config={ "timeout_ms": 30000, @@ -214,7 +214,7 @@ def contract_with_nulls() -> ModelTestOrchestratorContract: """Contract with explicit null fields for normalization testing.""" return ModelTestOrchestratorContract( name="nullable_orchestrator", - version="1.0.0", + contract_version="1.0.0", description=None, workflow_config={ "enabled": True, @@ -275,7 +275,9 @@ def test_deterministic_across_normalization( def test_order_of_execution_does_not_affect_result(self) -> None: """Test that execution order doesn't affect fingerprint results.""" contracts = [ - ModelTestOrchestratorContract(name=f"orchestrator_{i}", version="1.0.0") + ModelTestOrchestratorContract( + name=f"orchestrator_{i}", contract_version="1.0.0" + ) for i in range(5) ] @@ -372,13 +374,13 @@ def test_same_contract_same_fingerprint(self) -> None: """Test that identical contracts produce identical fingerprints.""" contract1 = ModelTestOrchestratorContract( name="test_orchestrator", - version="1.0.0", + contract_version="1.0.0", description="Test description", execution_mode="parallel", ) contract2 = ModelTestOrchestratorContract( name="test_orchestrator", - version="1.0.0", + contract_version="1.0.0", description="Test description", execution_mode="parallel", ) @@ -392,12 +394,12 @@ def test_fingerprint_changes_when_content_changes(self) -> None: """Test that fingerprint changes when any content changes.""" base = ModelTestOrchestratorContract( name="orchestrator", - version="1.0.0", + contract_version="1.0.0", execution_mode="sequential", ) modified = ModelTestOrchestratorContract( name="orchestrator", - version="1.0.0", + contract_version="1.0.0", execution_mode="parallel", # Changed field ) @@ -410,8 +412,8 @@ def test_fingerprint_changes_when_content_changes(self) -> None: def test_fingerprint_changes_when_version_changes(self) -> None: """Test that fingerprint changes when version changes.""" - v1 = ModelTestOrchestratorContract(name="test", version="1.0.0") - v2 = ModelTestOrchestratorContract(name="test", version="1.0.1") + v1 = ModelTestOrchestratorContract(name="test", contract_version="1.0.0") + v2 = ModelTestOrchestratorContract(name="test", contract_version="1.0.1") fp_v1 = compute_contract_fingerprint(v1) fp_v2 = compute_contract_fingerprint(v2) @@ -430,7 +432,7 @@ def test_fingerprint_stable_with_null_normalization( # Equivalent contract without explicit nulls without_nulls = ModelTestOrchestratorContract( name="nullable_orchestrator", - version="1.0.0", + contract_version="1.0.0", workflow_config={ "enabled": True, "nested": { @@ -449,12 +451,12 @@ def test_key_order_does_not_affect_fingerprint(self) -> None: """Test that dict key order doesn't affect fingerprint.""" contract1 = ModelTestOrchestratorContract( name="test", - version="1.0.0", + contract_version="1.0.0", workflow_config={"alpha": 1, "beta": 2, "gamma": 3}, ) contract2 = ModelTestOrchestratorContract( name="test", - version="1.0.0", + contract_version="1.0.0", workflow_config={"gamma": 3, "alpha": 1, "beta": 2}, ) @@ -503,8 +505,10 @@ class TestVersionCompatibility: def test_version_field_validated(self) -> None: """Test that version field undergoes validation.""" # Valid version string - contract = ModelMinimalContract(name="test", version="1.2.3") - assert contract.version == ModelContractVersion(major=1, minor=2, patch=3) + contract = ModelMinimalContract(name="test", contract_version="1.2.3") + assert contract.contract_version == ModelContractVersion( + major=1, minor=2, patch=3 + ) def test_semver_format_accepted(self) -> None: """Test various valid semver formats are accepted.""" @@ -518,7 +522,7 @@ def test_semver_format_accepted(self) -> None: ] for version_str in valid_versions: - contract = ModelMinimalContract(name="test", version=version_str) + contract = ModelMinimalContract(name="test", contract_version=version_str) fingerprint = compute_contract_fingerprint(contract) assert str(fingerprint).startswith(f"{version_str}:") @@ -584,7 +588,7 @@ def test_version_in_fingerprint_matches_contract(self) -> None: for version_str in ["0.1.0", "1.0.0", "2.3.4", "10.0.0"]: contract = ModelTestOrchestratorContract( name="test", - version=version_str, + contract_version=version_str, ) fingerprint = compute_contract_fingerprint(contract) assert str(fingerprint.version) == version_str @@ -632,12 +636,12 @@ def test_semantically_identical_contracts_same_result(self) -> None: # Two contracts created differently but semantically identical contract1 = ModelTestOrchestratorContract( name="orchestrator", - version=ModelContractVersion(major=1, minor=0, patch=0), + contract_version=ModelContractVersion(major=1, minor=0, patch=0), execution_mode="parallel", ) contract2 = ModelTestOrchestratorContract( name="orchestrator", - version="1.0.0", # String version instead of object + contract_version="1.0.0", # String version instead of object execution_mode="parallel", ) @@ -657,7 +661,7 @@ def test_field_order_does_not_affect_identity(self) -> None: contracts = [ ModelTestOrchestratorContract( name="test", - version="1.0.0", + contract_version="1.0.0", workflow_config=config, ) for config in [config1, config2, config3] @@ -671,7 +675,7 @@ def test_default_values_do_not_affect_identity(self) -> None: # Contract with explicit defaults explicit = ModelTestOrchestratorContract( name="test_orchestrator", - version="1.0.0", + contract_version="1.0.0", node_type="ORCHESTRATOR_GENERIC", # Default value execution_mode="sequential", # Default value max_parallel_branches=4, # Default value @@ -683,7 +687,7 @@ def test_default_values_do_not_affect_identity(self) -> None: # Contract relying on defaults implicit = ModelTestOrchestratorContract( name="test_orchestrator", - version="1.0.0", + contract_version="1.0.0", ) fp_explicit = compute_contract_fingerprint(explicit) @@ -695,7 +699,7 @@ def test_nested_dict_order_does_not_affect_identity(self) -> None: """Test that deeply nested dict order doesn't affect identity.""" contract1 = ModelTestOrchestratorContract( name="nested_test", - version="1.0.0", + contract_version="1.0.0", workflow_config={ "outer": { "inner": { @@ -711,7 +715,7 @@ def test_nested_dict_order_does_not_affect_identity(self) -> None: ) contract2 = ModelTestOrchestratorContract( name="nested_test", - version="1.0.0", + contract_version="1.0.0", workflow_config={ "outer": { "another": { @@ -735,14 +739,14 @@ def test_empty_lists_preserved_in_identity(self) -> None: """Test that empty lists are preserved and affect identity correctly.""" with_empty = ModelTestOrchestratorContract( name="test", - version="1.0.0", + contract_version="1.0.0", tags=[], # Explicit empty list ) # Tags default to empty list with_default = ModelTestOrchestratorContract( name="test", - version="1.0.0", + contract_version="1.0.0", ) fp_with = compute_contract_fingerprint(with_empty) @@ -755,12 +759,12 @@ def test_whitespace_in_string_values_preserved(self) -> None: """Test that whitespace in string values is preserved in identity.""" contract1 = ModelTestOrchestratorContract( name="test", - version="1.0.0", + contract_version="1.0.0", description="no whitespace", ) contract2 = ModelTestOrchestratorContract( name="test", - version="1.0.0", + contract_version="1.0.0", description="no whitespace", # Extra space ) @@ -813,7 +817,7 @@ def test_hash_includes_all_relevant_fields(self) -> None: """Test that changing any relevant field changes the hash.""" base_contract = ModelTestOrchestratorContract( name="base", - version="1.0.0", + contract_version="1.0.0", description="original", execution_mode="sequential", max_parallel_branches=4, @@ -826,7 +830,7 @@ def test_hash_includes_all_relevant_fields(self) -> None: # Explicitly typed for mypy strict mode field_changes: list[dict[str, object]] = [ {"name": "changed"}, - {"version": "1.0.1"}, + {"contract_version": "1.0.1"}, {"description": "changed"}, {"execution_mode": "parallel"}, {"max_parallel_branches": 8}, @@ -861,7 +865,7 @@ def test_hash_is_lowercase_hex( def test_different_configs_produce_different_hashes(self) -> None: """Test that different normalization configs can affect hash length but not value.""" - contract = ModelTestOrchestratorContract(name="test", version="1.0.0") + contract = ModelTestOrchestratorContract(name="test", contract_version="1.0.0") config_short = ModelContractNormalizationConfig(hash_length=8) config_long = ModelContractNormalizationConfig(hash_length=32) @@ -954,12 +958,12 @@ def test_registry_detects_version_drift( ) -> None: """Test registry detects version drift in orchestrator contracts.""" v1_contract = ModelTestOrchestratorContract( - name="orchestrator", version="1.0.0" + name="orchestrator", contract_version="1.0.0" ) registry.register_from_contract("orchestrator", v1_contract) v2_contract = ModelTestOrchestratorContract( - name="orchestrator", version="2.0.0" + name="orchestrator", contract_version="2.0.0" ) drift = registry.detect_drift_from_contract("orchestrator", v2_contract) @@ -973,14 +977,14 @@ def test_registry_detects_content_drift( """Test registry detects content drift in orchestrator contracts.""" original = ModelTestOrchestratorContract( name="orchestrator", - version="1.0.0", + contract_version="1.0.0", execution_mode="sequential", ) registry.register_from_contract("orchestrator", original) modified = ModelTestOrchestratorContract( name="orchestrator", - version="1.0.0", + contract_version="1.0.0", execution_mode="parallel", # Changed content ) drift = registry.detect_drift_from_contract("orchestrator", modified) @@ -1020,7 +1024,7 @@ class TestKnownFingerprints: def test_minimal_contract_fingerprint_stable(self) -> None: """Test that minimal contract produces stable fingerprint.""" - contract = ModelMinimalContract(name="test", version="1.0.0") + contract = ModelMinimalContract(name="test", contract_version="1.0.0") fingerprint = compute_contract_fingerprint(contract) # The fingerprint should be deterministic and stable @@ -1036,7 +1040,7 @@ def test_empty_nested_structure_normalization(self) -> None: """Test handling of empty nested structures after null removal.""" contract = ModelTestOrchestratorContract( name="test", - version="1.0.0", + contract_version="1.0.0", workflow_config={"only_null": None}, ) @@ -1051,7 +1055,7 @@ def test_complex_nested_structure_fingerprint(self) -> None: """Test fingerprint stability for complex nested structures.""" contract = ModelTestOrchestratorContract( name="complex_orchestrator", - version="1.0.0", + contract_version="1.0.0", workflow_config={ "level1": { "level2": { @@ -1080,7 +1084,7 @@ def test_unicode_content_fingerprint(self) -> None: """Test fingerprint handling of unicode content.""" contract = ModelTestOrchestratorContract( name="unicode_test", - version="1.0.0", + contract_version="1.0.0", description="Testing unicode: \u00e9\u00e8\u00ea \u4e2d\u6587 \U0001f600", workflow_config={ "emoji_key": "\U0001f4dd", @@ -1098,7 +1102,7 @@ def test_special_characters_in_strings(self) -> None: """Test fingerprint handling of special characters.""" contract = ModelTestOrchestratorContract( name="special_chars", - version="1.0.0", + contract_version="1.0.0", description="Quotes: \"double\" and 'single', backslash: \\, newline: \n, tab: \t", ) @@ -1110,7 +1114,7 @@ def test_special_characters_in_strings(self) -> None: def test_zero_version_fingerprint(self) -> None: """Test fingerprint for contracts starting at version 0.0.0.""" - contract = ModelMinimalContract(name="initial", version="0.0.0") + contract = ModelMinimalContract(name="initial", contract_version="0.0.0") fingerprint = compute_contract_fingerprint(contract) assert str(fingerprint).startswith("0.0.0:") @@ -1118,7 +1122,7 @@ def test_zero_version_fingerprint(self) -> None: def test_large_version_numbers(self) -> None: """Test fingerprint handles large version numbers.""" - contract = ModelMinimalContract(name="test", version="999.999.999") + contract = ModelMinimalContract(name="test", contract_version="999.999.999") fingerprint = compute_contract_fingerprint(contract) assert str(fingerprint).startswith("999.999.999:") diff --git a/tests/unit/protocols/handler/contracts/test_protocol_handler_contracts.py b/tests/unit/protocols/handler/contracts/test_protocol_handler_contracts.py index a2ba285d9c..e5329f8522 100644 --- a/tests/unit/protocols/handler/contracts/test_protocol_handler_contracts.py +++ b/tests/unit/protocols/handler/contracts/test_protocol_handler_contracts.py @@ -24,6 +24,7 @@ import pytest +from omnibase_core.models.primitives.model_semver import ModelSemVer from omnibase_core.protocols.handler.contracts import ( ProtocolCapabilityDependency, ProtocolExecutionConstrainable, @@ -217,14 +218,16 @@ def __init__( self, handler_id: str = "test-handler-001", handler_name: str = "test-handler", - handler_version: str = "1.0.0", + contract_version: ModelSemVer | None = None, descriptor: ProtocolHandlerBehaviorDescriptor | None = None, capability_inputs: list[ProtocolCapabilityDependency] | None = None, execution_constraints: ProtocolExecutionConstraints | None = None, ) -> None: self._handler_id = handler_id self._handler_name = handler_name - self._handler_version = handler_version + self._contract_version = contract_version or ModelSemVer( + major=1, minor=0, patch=0 + ) self._descriptor = descriptor or DuckTypedBehaviorDescriptor() self._capability_inputs = capability_inputs or [] self._execution_constraints = execution_constraints @@ -238,8 +241,8 @@ def handler_name(self) -> str: return self._handler_name @property - def handler_version(self) -> str: - return self._handler_version + def contract_version(self) -> ModelSemVer: + return self._contract_version @property def descriptor(self) -> ProtocolHandlerBehaviorDescriptor: @@ -415,7 +418,7 @@ def test_handler_contract_duck_typing(self) -> None: contract = DuckTypedHandlerContract( handler_id="http-handler-001", handler_name="http-rest-handler", - handler_version="2.1.0", + contract_version=ModelSemVer(major=2, minor=1, patch=0), descriptor=descriptor, capability_inputs=[capability], execution_constraints=constraints, @@ -424,7 +427,7 @@ def test_handler_contract_duck_typing(self) -> None: # Verify all contract properties assert contract.handler_id == "http-handler-001" assert contract.handler_name == "http-rest-handler" - assert contract.handler_version == "2.1.0" + assert contract.contract_version == ModelSemVer(major=2, minor=1, patch=0) assert contract.descriptor.idempotent is True assert len(contract.capability_inputs) == 1 assert contract.capability_inputs[0].capability_name == "database.postgresql" @@ -583,7 +586,7 @@ def test_handler_contract_property_types(self) -> None: assert isinstance(contract.handler_id, str) assert isinstance(contract.handler_name, str) - assert isinstance(contract.handler_version, str) + assert isinstance(contract.contract_version, ModelSemVer) assert isinstance(contract.descriptor, ProtocolHandlerBehaviorDescriptor) assert isinstance(contract.capability_inputs, list) assert contract.execution_constraints is None or isinstance( @@ -679,7 +682,7 @@ def test_complete_handler_contract_with_all_components(self) -> None: contract = DuckTypedHandlerContract( handler_id="urn:onex:handler:user-auth:v1", handler_name="user-authentication-handler", - handler_version="1.2.3", + contract_version=ModelSemVer(major=1, minor=2, patch=3), descriptor=descriptor, capability_inputs=[db_cap, cache_cap], execution_constraints=constraints, @@ -705,7 +708,7 @@ def test_handler_contract_without_constraints(self) -> None: contract = DuckTypedHandlerContract( handler_id="simple-handler", handler_name="simple", - handler_version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), execution_constraints=None, ) @@ -790,7 +793,7 @@ def test_handler_contract_empty_capabilities(self) -> None: contract = DuckTypedHandlerContract( handler_id="no-deps-handler", handler_name="standalone", - handler_version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), capability_inputs=[], ) diff --git a/tests/unit/resolution/test_execution_resolver.py b/tests/unit/resolution/test_execution_resolver.py index a76818c615..6f7a9f1c3c 100644 --- a/tests/unit/resolution/test_execution_resolver.py +++ b/tests/unit/resolution/test_execution_resolver.py @@ -16,6 +16,8 @@ .. versionadded:: 0.4.1 """ +from __future__ import annotations + from typing import Any import pytest @@ -34,6 +36,7 @@ ModelExecutionProfile, ) from omnibase_core.models.contracts.model_handler_contract import ModelHandlerContract +from omnibase_core.models.primitives.model_semver import ModelSemVer from omnibase_core.models.runtime.model_handler_behavior import ModelHandlerBehavior from omnibase_core.resolution import ExecutionResolver @@ -114,7 +117,7 @@ def _create_contract( return ModelHandlerContract( handler_id=handler_id, name=f"Handler {handler_id}", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="test.Input", output_model="test.Output", @@ -1294,7 +1297,7 @@ def test_handler_kind_effect_assigned_to_execute_phase( effect_contract = ModelHandlerContract( handler_id="handler.effect_io", name="Effect IO Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="effect"), input_model="test.Input", output_model="test.Output", @@ -1325,7 +1328,7 @@ def test_handler_kind_reducer_assigned_to_execute_phase( reducer_contract = ModelHandlerContract( handler_id="handler.reducer_state", name="Reducer State Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="reducer"), input_model="test.Input", output_model="test.Output", @@ -1356,7 +1359,7 @@ def test_handler_kind_orchestrator_assigned_to_execute_phase( orchestrator_contract = ModelHandlerContract( handler_id="handler.orchestrator_workflow", name="Orchestrator Workflow Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="orchestrator"), input_model="test.Input", output_model="test.Output", @@ -1389,7 +1392,7 @@ def test_mixed_handler_kinds_all_in_execute_phase( ModelHandlerContract( handler_id="handler.compute", name="Compute Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="test.Input", output_model="test.Output", @@ -1398,7 +1401,7 @@ def test_mixed_handler_kinds_all_in_execute_phase( ModelHandlerContract( handler_id="handler.effect", name="Effect Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="effect"), input_model="test.Input", output_model="test.Output", @@ -1407,7 +1410,7 @@ def test_mixed_handler_kinds_all_in_execute_phase( ModelHandlerContract( handler_id="handler.reducer", name="Reducer Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="reducer"), input_model="test.Input", output_model="test.Output", @@ -1416,7 +1419,7 @@ def test_mixed_handler_kinds_all_in_execute_phase( ModelHandlerContract( handler_id="handler.orchestrator", name="Orchestrator Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="orchestrator"), input_model="test.Input", output_model="test.Output", @@ -1454,7 +1457,7 @@ def test_phase_ordering_respects_dependency_within_execute( ModelHandlerContract( handler_id="handler.validation", name="Validation Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="test.Input", output_model="test.Output", @@ -1463,7 +1466,7 @@ def test_phase_ordering_respects_dependency_within_execute( ModelHandlerContract( handler_id="handler.process", name="Process Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="test.Input", output_model="test.Output", @@ -1475,7 +1478,7 @@ def test_phase_ordering_respects_dependency_within_execute( ModelHandlerContract( handler_id="handler.finalize", name="Finalize Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="effect"), input_model="test.Input", output_model="test.Output", diff --git a/tests/unit/resolution/test_execution_resolver_performance.py b/tests/unit/resolution/test_execution_resolver_performance.py index 8e582852c9..c09c07069a 100644 --- a/tests/unit/resolution/test_execution_resolver_performance.py +++ b/tests/unit/resolution/test_execution_resolver_performance.py @@ -38,6 +38,7 @@ ModelExecutionProfile, ) from omnibase_core.models.contracts.model_handler_contract import ModelHandlerContract +from omnibase_core.models.primitives.model_semver import ModelSemVer from omnibase_core.models.runtime.model_handler_behavior import ModelHandlerBehavior from omnibase_core.resolution import ExecutionResolver @@ -124,7 +125,7 @@ def _create_contract( return ModelHandlerContract( handler_id=handler_id, name=f"Handler {handler_id}", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=ModelHandlerBehavior(handler_kind="compute"), input_model="test.Input", output_model="test.Output", diff --git a/tests/unit/scripts/test_compute_contract_fingerprint.py b/tests/unit/scripts/test_compute_contract_fingerprint.py new file mode 100644 index 0000000000..95c83403d9 --- /dev/null +++ b/tests/unit/scripts/test_compute_contract_fingerprint.py @@ -0,0 +1,347 @@ +""" +Unit tests for compute_contract_fingerprint.py script. + +Tests the contract type detection and validation functions, particularly +the split between validate_strict_contract() (enforcement) and +is_strict_contract() (predicate). + +Test Categories: +1. validate_strict_contract() - Enforcement function tests +2. is_strict_contract() - Predicate wrapper tests +3. detect_contract_model() - Model detection tests +4. Deprecated 'version' field rejection tests (OMN-1431/OMN-1436) +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +# Load script as module using importlib (cleaner than sys.path manipulation) +_script_path = ( + Path(__file__).parent.parent.parent.parent + / "scripts" + / "compute_contract_fingerprint.py" +) +_spec = importlib.util.spec_from_file_location( + "compute_contract_fingerprint", _script_path +) +assert _spec is not None and _spec.loader is not None, ( + f"Failed to load spec from {_script_path}" +) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) + +# Import functions from loaded module +detect_contract_model = _module.detect_contract_model +is_strict_contract = _module.is_strict_contract +validate_strict_contract = _module.validate_strict_contract + +from omnibase_core.enums.enum_core_error_code import EnumCoreErrorCode +from omnibase_core.models.contracts.model_contract_compute import ModelContractCompute +from omnibase_core.models.contracts.model_yaml_contract import ModelYamlContract +from omnibase_core.models.errors.model_onex_error import ModelOnexError + +# ============================================================================= +# Test Fixtures +# ============================================================================= + + +@pytest.fixture +def strict_contract_data() -> dict[str, object]: + """Valid strict contract data with all required fields.""" + return { + "name": "test_contract", + "contract_version": {"major": 1, "minor": 0, "patch": 0}, + "description": "A test contract", + "node_type": "COMPUTE_GENERIC", + "input_model": "omnibase_core.models.ModelInput", + "output_model": "omnibase_core.models.ModelOutput", + } + + +@pytest.fixture +def flexible_contract_data() -> dict[str, object]: + """Valid flexible contract data (missing strict fields).""" + return { + "name": "test_contract", + "contract_version": {"major": 1, "minor": 0, "patch": 0}, + "node_type": "COMPUTE_GENERIC", + # Missing: description, input_model, output_model + } + + +@pytest.fixture +def deprecated_version_contract_data() -> dict[str, object]: + """Contract data using deprecated 'version' field.""" + return { + "name": "test_contract", + "version": {"major": 1, "minor": 0, "patch": 0}, # Deprecated! + "description": "A test contract", + "node_type": "COMPUTE_GENERIC", + "input_model": "omnibase_core.models.ModelInput", + "output_model": "omnibase_core.models.ModelOutput", + } + + +@pytest.fixture +def both_versions_contract_data() -> dict[str, object]: + """Contract data with both 'version' and 'contract_version' (still invalid).""" + return { + "name": "test_contract", + "version": {"major": 1, "minor": 0, "patch": 0}, # Deprecated! + "contract_version": {"major": 1, "minor": 0, "patch": 0}, + "description": "A test contract", + "node_type": "COMPUTE_GENERIC", + "input_model": "omnibase_core.models.ModelInput", + "output_model": "omnibase_core.models.ModelOutput", + } + + +# ============================================================================= +# validate_strict_contract() Tests - Enforcement Function +# ============================================================================= + + +class TestValidateStrictContract: + """Tests for validate_strict_contract() enforcement function.""" + + def test_accepts_strict_contract_with_contract_version( + self, strict_contract_data: dict[str, object] + ) -> None: + """Test that strict contracts with contract_version are accepted.""" + result = validate_strict_contract(strict_contract_data) + assert result is True + + def test_accepts_flexible_contract_with_contract_version( + self, flexible_contract_data: dict[str, object] + ) -> None: + """Test that flexible contracts with contract_version return False.""" + result = validate_strict_contract(flexible_contract_data) + assert result is False + + def test_rejects_deprecated_version_field( + self, deprecated_version_contract_data: dict[str, object] + ) -> None: + """Test that contracts with deprecated 'version' field are rejected.""" + with pytest.raises(ModelOnexError) as exc_info: + validate_strict_contract(deprecated_version_contract_data) + + error = exc_info.value + assert error.error_code == EnumCoreErrorCode.CONTRACT_VALIDATION_ERROR + assert "version" in str(error) + assert "contract_version" in str(error) + + def test_rejects_version_even_when_contract_version_exists( + self, both_versions_contract_data: dict[str, object] + ) -> None: + """Test that deprecated 'version' is rejected even with 'contract_version' present. + + This is the critical migration footgun test - someone adds contract_version + but forgets to remove version. The system must reject this. + """ + with pytest.raises(ModelOnexError) as exc_info: + validate_strict_contract(both_versions_contract_data) + + error = exc_info.value + assert error.error_code == EnumCoreErrorCode.CONTRACT_VALIDATION_ERROR + # Verify the error context includes info about both fields being present + assert error.context is not None + inner_context = error.context.get("additional_context", {}).get("context", {}) + assert inner_context.get("has_contract_version") is True + + def test_error_includes_migration_guidance( + self, deprecated_version_contract_data: dict[str, object] + ) -> None: + """Test that error message includes helpful migration guidance.""" + with pytest.raises(ModelOnexError) as exc_info: + validate_strict_contract(deprecated_version_contract_data) + + error = exc_info.value + # Check that context includes actionable guidance + assert error.context is not None + inner_context = error.context.get("additional_context", {}).get("context", {}) + assert "suggestion" in inner_context + assert "OMN-1431" in error.message + + +# ============================================================================= +# is_strict_contract() Tests - Predicate Wrapper +# ============================================================================= + + +class TestIsStrictContract: + """Tests for is_strict_contract() predicate wrapper. + + This function should never raise exceptions - it wraps validate_strict_contract() + and returns False on validation failure. + """ + + def test_returns_true_for_strict_contract( + self, strict_contract_data: dict[str, object] + ) -> None: + """Test that strict contracts return True.""" + result = is_strict_contract(strict_contract_data) + assert result is True + + def test_returns_false_for_flexible_contract( + self, flexible_contract_data: dict[str, object] + ) -> None: + """Test that flexible contracts return False.""" + result = is_strict_contract(flexible_contract_data) + assert result is False + + def test_returns_false_for_deprecated_version_no_exception( + self, deprecated_version_contract_data: dict[str, object] + ) -> None: + """Test that deprecated 'version' returns False instead of raising. + + Unlike validate_strict_contract(), the predicate should not raise. + """ + result = is_strict_contract(deprecated_version_contract_data) + assert result is False + + def test_returns_false_for_both_versions_no_exception( + self, both_versions_contract_data: dict[str, object] + ) -> None: + """Test that 'both versions present' returns False instead of raising.""" + result = is_strict_contract(both_versions_contract_data) + assert result is False + + +# ============================================================================= +# detect_contract_model() Tests +# ============================================================================= + + +class TestDetectContractModel: + """Tests for detect_contract_model() function.""" + + def test_detects_compute_model_for_strict_contract( + self, strict_contract_data: dict[str, object] + ) -> None: + """Test that COMPUTE_GENERIC maps to ModelContractCompute.""" + model_class = detect_contract_model(strict_contract_data) + assert model_class is ModelContractCompute + + def test_detects_yaml_model_for_flexible_contract( + self, flexible_contract_data: dict[str, object] + ) -> None: + """Test that flexible contracts use ModelYamlContract.""" + model_class = detect_contract_model(flexible_contract_data) + assert model_class is ModelYamlContract + + def test_raises_for_deprecated_version_field( + self, deprecated_version_contract_data: dict[str, object] + ) -> None: + """Test that detect_contract_model raises for deprecated 'version' field.""" + with pytest.raises(ModelOnexError) as exc_info: + detect_contract_model(deprecated_version_contract_data) + + error = exc_info.value + assert error.error_code == EnumCoreErrorCode.CONTRACT_VALIDATION_ERROR + + def test_raises_for_missing_node_type(self) -> None: + """Test that missing node_type raises CONTRACT_VALIDATION_ERROR.""" + contract_data = { + "name": "test", + "contract_version": {"major": 1, "minor": 0, "patch": 0}, + } + with pytest.raises(ModelOnexError) as exc_info: + detect_contract_model(contract_data) + + error = exc_info.value + assert error.error_code == EnumCoreErrorCode.CONTRACT_VALIDATION_ERROR + assert "node_type" in error.message + + def test_raises_for_invalid_node_type_type(self) -> None: + """Test that non-string node_type raises CONTRACT_VALIDATION_ERROR.""" + contract_data = { + "name": "test", + "contract_version": {"major": 1, "minor": 0, "patch": 0}, + "node_type": 123, # Invalid: not a string + } + with pytest.raises(ModelOnexError) as exc_info: + detect_contract_model(contract_data) + + error = exc_info.value + assert error.error_code == EnumCoreErrorCode.CONTRACT_VALIDATION_ERROR + assert "string" in error.message + + +# ============================================================================= +# Edge Cases +# ============================================================================= + + +class TestEdgeCases: + """Edge case tests for contract validation.""" + + def test_empty_contract_is_flexible(self) -> None: + """Test that empty contract data returns False (flexible).""" + result = is_strict_contract({}) + assert result is False + + def test_contract_with_only_node_type(self) -> None: + """Test contract with only node_type is flexible.""" + contract_data = {"node_type": "COMPUTE_GENERIC"} + result = is_strict_contract(contract_data) + assert result is False + + def test_strict_fields_without_contract_version_raises_error(self) -> None: + """Test that strict fields without contract_version raises error. + + Contracts with strict fields MUST include contract_version - no backwards compatibility. + """ + contract_data = { + "name": "test", + "description": "A test", + "node_type": "COMPUTE_GENERIC", + "input_model": "omnibase_core.models.ModelInput", + "output_model": "omnibase_core.models.ModelOutput", + # No contract_version - but has all strict fields + } + with pytest.raises(ModelOnexError) as exc_info: + validate_strict_contract(contract_data) + + error = exc_info.value + assert error.error_code == EnumCoreErrorCode.CONTRACT_VALIDATION_ERROR + assert "contract_version" in str(error) + + def test_is_strict_contract_returns_false_for_missing_contract_version( + self, + ) -> None: + """Test that is_strict_contract returns False (not raises) for missing contract_version. + + The predicate wrapper should catch the error and return False. + """ + contract_data = { + "name": "test", + "description": "A test", + "node_type": "COMPUTE_GENERIC", + "input_model": "omnibase_core.models.ModelInput", + "output_model": "omnibase_core.models.ModelOutput", + # No contract_version + } + result = is_strict_contract(contract_data) + assert result is False # Returns False instead of raising + + def test_missing_contract_version_error_includes_context(self) -> None: + """Test that missing contract_version error includes helpful context.""" + contract_data = { + "name": "test", + "description": "A test", + "node_type": "COMPUTE_GENERIC", + "input_model": "omnibase_core.models.ModelInput", + "output_model": "omnibase_core.models.ModelOutput", + } + with pytest.raises(ModelOnexError) as exc_info: + validate_strict_contract(contract_data) + + error = exc_info.value + assert error.context is not None + inner_context = error.context.get("additional_context", {}).get("context", {}) + assert inner_context.get("has_strict_fields") is True + assert "suggestion" in inner_context diff --git a/tests/unit/scripts/test_lint_contract.py b/tests/unit/scripts/test_lint_contract.py index 1a96b4ce26..eb2fde3490 100644 --- a/tests/unit/scripts/test_lint_contract.py +++ b/tests/unit/scripts/test_lint_contract.py @@ -70,12 +70,12 @@ class ModelSimpleTestContract(BaseModel): This is a simplified contract model that can be validated without the complex requirements of production contract models. - Note: version field accepts both string ("1.0.0") and ModelContractVersion + Note: contract_version field accepts both string ("1.0.0") and ModelContractVersion for test convenience, but is stored as ModelContractVersion. """ name: str = Field(default="test_contract") - version: ModelContractVersion = Field( + contract_version: ModelContractVersion = Field( default_factory=lambda: ModelContractVersion(major=1, minor=0, patch=0) ) description: str | None = Field(default=None) @@ -83,7 +83,7 @@ class ModelSimpleTestContract(BaseModel): input_model: str = Field(default="omnibase_core.models.ModelInput") output_model: str = Field(default="omnibase_core.models.ModelOutput") - @field_validator("version", mode="before") + @field_validator("contract_version", mode="before") @classmethod def convert_version_string(cls, v: object) -> ModelContractVersion: """Convert string versions to ModelContractVersion for test convenience.""" diff --git a/tests/unit/scripts/test_regenerate_fingerprints.py b/tests/unit/scripts/test_regenerate_fingerprints.py index ce1516fa23..59c05bc546 100644 --- a/tests/unit/scripts/test_regenerate_fingerprints.py +++ b/tests/unit/scripts/test_regenerate_fingerprints.py @@ -429,7 +429,7 @@ def test_compute_fingerprint_for_contract_consistency(self) -> None: """Test compute_fingerprint_for_contract returns consistent results.""" contract_data = { "name": "TestContract", - "version": {"major": 1, "minor": 0, "patch": 0}, + "contract_version": {"major": 1, "minor": 0, "patch": 0}, "node_type": "COMPUTE_GENERIC", "description": "Test contract", "input_model": "ModelInput", @@ -713,7 +713,7 @@ def test_detect_compute_model(self) -> None: data = { "node_type": "COMPUTE_GENERIC", "name": "TestCompute", - "version": "1.0.0", + "contract_version": "1.0.0", "input_model": "ModelInput", "output_model": "ModelOutput", "description": "Test", @@ -731,7 +731,7 @@ def test_detect_effect_model(self) -> None: data = { "node_type": "EFFECT_GENERIC", "name": "TestEffect", - "version": "1.0.0", + "contract_version": "1.0.0", "input_model": "ModelInput", "output_model": "ModelOutput", "description": "Test", @@ -761,7 +761,7 @@ def test_missing_node_type_returns_none(self) -> None: """Test that missing node_type returns None.""" data = { "name": "TestNoNodeType", - "version": "1.0.0", + "contract_version": "1.0.0", } model = detect_contract_model(data) @@ -961,7 +961,7 @@ def test_detect_reducer_model(self) -> None: data = { "node_type": "REDUCER_GENERIC", "name": "TestReducer", - "version": "1.0.0", + "contract_version": "1.0.0", "input_model": "ModelInput", "output_model": "ModelOutput", "description": "Test", @@ -980,7 +980,7 @@ def test_detect_orchestrator_model(self) -> None: data = { "node_type": "ORCHESTRATOR_GENERIC", "name": "TestOrchestrator", - "version": "1.0.0", + "contract_version": "1.0.0", "input_model": "ModelInput", "output_model": "ModelOutput", "description": "Test", @@ -995,7 +995,7 @@ def test_detect_transformer_model(self) -> None: data = { "node_type": "TRANSFORMER", "name": "TestTransformer", - "version": "1.0.0", + "contract_version": "1.0.0", "input_model": "ModelInput", "output_model": "ModelOutput", "description": "Test", @@ -1014,7 +1014,7 @@ def test_detect_gateway_model(self) -> None: data = { "node_type": "GATEWAY", "name": "TestGateway", - "version": "1.0.0", + "contract_version": "1.0.0", "input_model": "ModelInput", "output_model": "ModelOutput", "description": "Test", @@ -1214,13 +1214,14 @@ def test_dict_style_version_preserves_node_type(self) -> None: class TestFingerprintComputationEdgeCases: """Edge case tests for fingerprint computation.""" - def test_compute_fingerprint_with_schema_validation_error(self) -> None: - """Test that schema validation errors are handled gracefully.""" - # Create contract data that will fail schema validation + def test_compute_fingerprint_with_invalid_node_type(self) -> None: + """Test that invalid node_type is handled gracefully.""" + # Create contract data with invalid node_type (not a string) + # This triggers model detection failure, not schema validation error contract_data = { - "node_type": "COMPUTE_GENERIC", + "node_type": 12345, # Invalid: should be a string "name": "TestBadContract", - "version": "invalid_version", # Invalid version format + "contract_version": "1.0.0", "input_model": "ModelInput", "output_model": "ModelOutput", "description": "Test", @@ -1231,7 +1232,7 @@ def test_compute_fingerprint_with_schema_validation_error(self) -> None: # Should return FingerprintResult with error (not raise) assert result.fingerprint is None assert result.error is not None - assert "validation" in result.error.lower() or "error" in result.error.lower() + assert "detect" in result.error.lower() or "model" in result.error.lower() def test_compute_fingerprint_with_missing_required_fields(self) -> None: """Test fingerprint computation with missing required fields.""" diff --git a/tests/unit/validation/phases/test_contract_validation_performance.py b/tests/unit/validation/phases/test_contract_validation_performance.py index c61d8904f0..da41a61667 100644 --- a/tests/unit/validation/phases/test_contract_validation_performance.py +++ b/tests/unit/validation/phases/test_contract_validation_performance.py @@ -56,7 +56,7 @@ def create_contract_with_outputs( return ModelHandlerContract( handler_id="node.test.compute", name=f"Test Handler with {num_outputs} outputs", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), description=f"A test contract with {num_outputs} capability outputs", descriptor=valid_descriptor, input_model="omnibase_core.models.events.ModelTestEvent", @@ -82,7 +82,7 @@ def create_valid_contract( return ModelHandlerContract( handler_id="node.test.compute", name=f"Test Handler {suffix}", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), description="Test contract for concurrent validation", descriptor=valid_descriptor, input_model="omnibase_core.models.events.ModelTestEvent", @@ -529,7 +529,7 @@ def create_invalid_contract(suffix: str) -> ModelHandlerContract: return ModelHandlerContract( handler_id="node.test.compute", name="TODO", # Placeholder - should fail validation - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), description="Invalid contract for testing", descriptor=valid_descriptor, input_model="omnibase_core.models.events.ModelTestEvent", @@ -749,7 +749,7 @@ def create_invalid_contract_for_merge() -> ModelHandlerContract: return ModelHandlerContract( handler_id="node.test.compute", name="PLACEHOLDER", # Should fail merge validation - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), description="Invalid", descriptor=valid_descriptor, input_model="omnibase_core.models.events.ModelTestEvent", diff --git a/tests/unit/validation/phases/test_expanded_validator.py b/tests/unit/validation/phases/test_expanded_validator.py index cf97baff09..7d081ec7da 100644 --- a/tests/unit/validation/phases/test_expanded_validator.py +++ b/tests/unit/validation/phases/test_expanded_validator.py @@ -17,6 +17,8 @@ - OMN-1128: Contract Validation Pipeline """ +from __future__ import annotations + import pytest from pydantic import ValidationError @@ -32,6 +34,7 @@ ) from omnibase_core.models.contracts.model_handler_contract import ModelHandlerContract from omnibase_core.models.errors.model_onex_error import ModelOnexError +from omnibase_core.models.primitives.model_semver import ModelSemVer from omnibase_core.models.runtime.model_handler_behavior import ModelHandlerBehavior from omnibase_core.validation.phases import ( ExpandedContractGraphValidator, @@ -70,7 +73,7 @@ def valid_contract( return ModelHandlerContract( handler_id="node.test.compute", name="Test Compute Node", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), description="A test compute node", descriptor=valid_descriptor, input_model="omnibase_core.models.events.ModelTestEvent", @@ -138,7 +141,7 @@ def test_valid_handler_id_passes( contract = ModelHandlerContract( handler_id=handler_id, name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -157,7 +160,7 @@ def test_single_segment_handler_id_fails( ModelHandlerContract( handler_id="single", # Should have at least 2 segments name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -190,7 +193,7 @@ def test_empty_input_model_fails( ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="", # Empty output_model="omnibase_core.models.test.Output", @@ -205,7 +208,7 @@ def test_invalid_model_reference_format_fails( contract = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="InvalidSingleWord", # Not dot-separated module path output_model="omnibase_core.models.test.Output", @@ -221,28 +224,38 @@ def test_invalid_model_reference_format_fails( @pytest.mark.unit class TestExpandedContractValidatorVersionFormat(TestExpandedContractValidatorFixtures): - """Tests for version format validation.""" + """Tests for contract_version validation (ModelSemVer). + + Note: As of OMN-1436, handler contracts use contract_version: ModelSemVer + instead of version: str. These tests validate the new structured version format. + """ def test_valid_semver_passes( self, validator: ExpandedContractValidator, valid_descriptor: ModelHandlerBehavior, ) -> None: - """Test that valid semver versions pass validation.""" + """Test that valid ModelSemVer versions pass validation.""" valid_versions = [ - "1.0.0", - "2.1.3", - "0.0.1", - "1.0.0-beta.1", - "1.0.0+build.123", - "1.0.0-alpha.1+build.456", + ModelSemVer(major=1, minor=0, patch=0), + ModelSemVer(major=2, minor=1, patch=3), + ModelSemVer(major=0, minor=0, patch=1), + ModelSemVer(major=1, minor=0, patch=0, prerelease=("beta", 1)), + ModelSemVer(major=1, minor=0, patch=0, build=("build", "123")), + ModelSemVer( + major=1, + minor=0, + patch=0, + prerelease=("alpha", 1), + build=("build", "456"), + ), ] for version in valid_versions: contract = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version=version, + contract_version=version, descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -250,19 +263,21 @@ def test_valid_semver_passes( result = validator.validate(contract) assert result.is_valid is True, f"Version '{version}' should be valid" - def test_non_standard_version_warning( + def test_deprecated_version_field_rejected( self, validator: ExpandedContractValidator, valid_descriptor: ModelHandlerBehavior, ) -> None: - """Test that non-standard version produces warning.""" - # ModelHandlerContract has a pattern validator that enforces semver format - # So non-semver versions will fail at model level - with pytest.raises(ValueError): + """Test that deprecated 'version' field is rejected. + + As of OMN-1436, handler contracts must use 'contract_version' (ModelSemVer), + not the deprecated 'version' (str) field. + """ + with pytest.raises(ModelOnexError, match="contract_version"): ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="v1.0", # Non-semver format + version="1.0.0", # type: ignore[call-arg] # Deprecated field - should be rejected descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -284,7 +299,7 @@ def test_self_reference_cycle_detected( contract = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -310,7 +325,7 @@ def test_valid_execution_constraints_pass( contract = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -343,7 +358,7 @@ def test_invalid_dependency_reference_format( ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -366,7 +381,7 @@ def test_valid_capability_output_format( contract = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -389,7 +404,7 @@ def test_invalid_capability_output_format_fails( contract = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -415,7 +430,7 @@ def test_event_output_info_message( contract = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -449,7 +464,7 @@ def test_valid_capability_input_format( contract = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -480,7 +495,7 @@ def test_invalid_capability_format_fails( ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -512,7 +527,7 @@ def test_compute_prefix_compute_kind_passes( contract = ModelHandlerContract( handler_id="compute.test.handler", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -534,7 +549,7 @@ def test_effect_prefix_effect_kind_passes( contract = ModelHandlerContract( handler_id="effect.email.sender", name="Email Sender", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -555,7 +570,7 @@ def test_generic_prefix_any_kind_passes( contract = ModelHandlerContract( handler_id=f"{prefix}.test.handler", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -577,7 +592,7 @@ def test_compute_prefix_effect_kind_fails( ModelHandlerContract( handler_id="compute.test.handler", # compute prefix name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=descriptor, # but effect kind input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -615,7 +630,7 @@ def test_orphan_handler_reference_detected( contract = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -642,7 +657,7 @@ def test_circular_dependency_detected( contract_a = ModelHandlerContract( handler_id="node.a.compute", name="Handler A", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -655,7 +670,7 @@ def test_circular_dependency_detected( contract_b = ModelHandlerContract( handler_id="node.b.compute", name="Handler B", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -682,7 +697,7 @@ def test_valid_dependency_chain( contract_a = ModelHandlerContract( handler_id="node.a.compute", name="Handler A", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -691,7 +706,7 @@ def test_valid_dependency_chain( contract_b = ModelHandlerContract( handler_id="node.b.compute", name="Handler B", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -703,7 +718,7 @@ def test_valid_dependency_chain( contract_c = ModelHandlerContract( handler_id="node.c.compute", name="Handler C", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -725,7 +740,7 @@ def test_unmatched_event_warning( producer = ModelHandlerContract( handler_id="node.producer.compute", name="Event Producer", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", diff --git a/tests/unit/validation/phases/test_merge_validator.py b/tests/unit/validation/phases/test_merge_validator.py index 74e671cdd3..eed98ac368 100644 --- a/tests/unit/validation/phases/test_merge_validator.py +++ b/tests/unit/validation/phases/test_merge_validator.py @@ -73,7 +73,7 @@ def valid_base( return ModelHandlerContract( handler_id="node.test.compute", name="Test Compute Node", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), description="A test compute node", descriptor=valid_descriptor, input_model="omnibase_core.models.events.ModelTestEvent", @@ -98,7 +98,7 @@ def valid_merged( return ModelHandlerContract( handler_id="node.test.compute", name="Updated Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), description="Updated description", descriptor=valid_descriptor, input_model="omnibase_core.models.events.ModelTestEvent", @@ -171,7 +171,7 @@ def test_placeholder_todo_detected( merged = ModelHandlerContract( handler_id="node.test.compute", name="TODO", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -196,7 +196,7 @@ def test_placeholder_template_var_detected( merged = ModelHandlerContract( handler_id="node.test.compute", name="${SERVICE_NAME}", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -217,7 +217,7 @@ def test_placeholder_jinja_style_detected( merged = ModelHandlerContract( handler_id="node.test.compute", name="{{handler_name}}", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -237,7 +237,7 @@ def test_placeholder_value_detected( merged = ModelHandlerContract( handler_id="node.test.compute", name="PLACEHOLDER", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -257,7 +257,7 @@ def test_placeholder_in_input_model( merged = ModelHandlerContract( handler_id="node.test.compute", name="Valid Name", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="TODO", output_model="omnibase_core.models.test.Output", @@ -278,7 +278,7 @@ def test_placeholder_in_output_model( merged = ModelHandlerContract( handler_id="node.test.compute", name="Valid Name", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="PLACEHOLDER", @@ -299,7 +299,7 @@ def test_placeholder_in_description_warning( merged = ModelHandlerContract( handler_id="node.test.compute", name="Valid Name", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), description="TODO: Add description", descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", @@ -343,7 +343,7 @@ def test_required_override_missing_detected( base = ModelHandlerContract( handler_id="node.test.compute", name="TODO", # Placeholder that should be overridden - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -353,7 +353,7 @@ def test_required_override_missing_detected( merged = ModelHandlerContract( handler_id="node.test.compute", name="TODO", # Same placeholder - not overridden - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -412,7 +412,7 @@ def test_handler_conflict_with_base_detected( base = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -458,7 +458,7 @@ def test_duplicate_capability_input_aliases_rejected_by_model( ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -488,7 +488,7 @@ def test_duplicate_capability_outputs_detected( merged = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -518,7 +518,7 @@ def test_capability_input_output_conflict_warning( merged = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -600,7 +600,7 @@ def test_multi_segment_handler_reference_no_false_positive( base = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -652,7 +652,7 @@ def test_handler_prefix_multi_segment_extraction( base = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -711,7 +711,7 @@ def test_multiple_errors_aggregated( merged = ModelHandlerContract( handler_id="node.test.compute", name="TODO", # Placeholder - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -755,7 +755,7 @@ def test_empty_capability_lists( merged = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=valid_descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", diff --git a/tests/unit/validation/test_contract_validation_pipeline.py b/tests/unit/validation/test_contract_validation_pipeline.py index 72ec73b3e8..a42419be0f 100644 --- a/tests/unit/validation/test_contract_validation_pipeline.py +++ b/tests/unit/validation/test_contract_validation_pipeline.py @@ -81,7 +81,7 @@ def valid_base( return ModelHandlerContract( handler_id="node.test.compute", name="Test Compute Node", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), description="A test compute node", descriptor=valid_descriptor, input_model="omnibase_core.models.events.ModelTestEvent", @@ -96,7 +96,7 @@ def valid_merged( return ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), description="Test handler description", descriptor=valid_descriptor, input_model="omnibase_core.models.events.ModelTestEvent", @@ -375,7 +375,7 @@ def test_success_result(self) -> None: contract = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -448,7 +448,7 @@ def test_validate_all_requires_profile_factory( merged = ModelHandlerContract( handler_id="node.test.compute", name="Merged Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -536,7 +536,7 @@ def test_validate_all_returns_contract_on_success( merged = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -591,7 +591,7 @@ def validate_side_effect( merged = ModelHandlerContract( handler_id="node.test.compute", name="Test Handler", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), descriptor=descriptor, input_model="omnibase_core.models.test.Input", output_model="omnibase_core.models.test.Output", @@ -722,7 +722,7 @@ def _create_contract_with_handlers( return ModelHandlerContract( handler_id="node.test.compute", name=f"Test Handler with {num_handlers} outputs", - version="1.0.0", + contract_version=ModelSemVer(major=1, minor=0, patch=0), description=f"A test contract with {num_handlers} capability outputs", descriptor=descriptor, input_model="omnibase_core.models.events.ModelTestEvent", diff --git a/tests/unit/validation/test_validate_contracts.py b/tests/unit/validation/test_validate_contracts.py index a889f51cae..c6bd290c40 100644 --- a/tests/unit/validation/test_validate_contracts.py +++ b/tests/unit/validation/test_validate_contracts.py @@ -1184,7 +1184,10 @@ def test_valid_handler_contract(self, temp_repo): valid_handler = """ handler_id: compute.schema.validator name: Schema Validator -version: 1.0.0 +contract_version: + major: 1 + minor: 0 + patch: 0 description: Validates input data against JSON schemas descriptor: @@ -1213,7 +1216,10 @@ def test_handler_contract_missing_handler_id(self, temp_repo): """Missing handler_id triggers error.""" invalid_handler = """ name: Schema Validator -version: 1.0.0 +contract_version: + major: 1 + minor: 0 + patch: 0 descriptor: handler_kind: compute purity: pure @@ -1233,7 +1239,10 @@ def test_handler_contract_missing_name(self, temp_repo): """Missing name triggers error.""" invalid_handler = """ handler_id: compute.schema.validator -version: 1.0.0 +contract_version: + major: 1 + minor: 0 + patch: 0 descriptor: handler_kind: compute purity: pure @@ -1248,8 +1257,8 @@ def test_handler_contract_missing_name(self, temp_repo): assert len(errors) >= 1 assert any("name" in error.lower() for error in errors) - def test_handler_contract_missing_version(self, temp_repo): - """Missing version triggers error.""" + def test_handler_contract_missing_contract_version(self, temp_repo): + """Missing contract_version triggers error.""" invalid_handler = """ handler_id: compute.schema.validator name: Schema Validator @@ -1260,19 +1269,22 @@ def test_handler_contract_missing_version(self, temp_repo): input_model: myapp.models.Input output_model: myapp.models.Output """ - yaml_file = temp_repo / "missing_version.yaml" + yaml_file = temp_repo / "missing_contract_version.yaml" yaml_file.write_text(invalid_handler) errors = validate_yaml_file(yaml_file) assert len(errors) >= 1 - assert any("version" in error.lower() for error in errors) + assert any("contract_version" in error.lower() for error in errors) def test_handler_contract_missing_descriptor(self, temp_repo): """Missing descriptor triggers error.""" invalid_handler = """ handler_id: compute.schema.validator name: Schema Validator -version: 1.0.0 +contract_version: + major: 1 + minor: 0 + patch: 0 input_model: myapp.models.Input output_model: myapp.models.Output """ @@ -1288,7 +1300,10 @@ def test_handler_contract_missing_input_model(self, temp_repo): invalid_handler = """ handler_id: compute.schema.validator name: Schema Validator -version: 1.0.0 +contract_version: + major: 1 + minor: 0 + patch: 0 descriptor: handler_kind: compute purity: pure @@ -1307,7 +1322,10 @@ def test_handler_contract_missing_output_model(self, temp_repo): invalid_handler = """ handler_id: compute.schema.validator name: Schema Validator -version: 1.0.0 +contract_version: + major: 1 + minor: 0 + patch: 0 descriptor: handler_kind: compute purity: pure @@ -1326,7 +1344,10 @@ def test_handler_contract_invalid_handler_id_format(self, temp_repo): invalid_handler = """ handler_id: single_segment name: Schema Validator -version: 1.0.0 +contract_version: + major: 1 + minor: 0 + patch: 0 descriptor: handler_kind: compute purity: pure @@ -1345,12 +1366,12 @@ def test_handler_contract_invalid_handler_id_format(self, temp_repo): for error in errors ) - def test_handler_contract_invalid_version_format(self, temp_repo): - """Invalid version format triggers error.""" + def test_handler_contract_invalid_contract_version_format(self, temp_repo): + """Invalid contract_version format triggers error.""" invalid_handler = """ handler_id: compute.schema.validator name: Schema Validator -version: not-a-version +contract_version: not-a-version descriptor: handler_kind: compute purity: pure @@ -1358,19 +1379,22 @@ def test_handler_contract_invalid_version_format(self, temp_repo): input_model: myapp.models.Input output_model: myapp.models.Output """ - yaml_file = temp_repo / "invalid_version.yaml" + yaml_file = temp_repo / "invalid_contract_version.yaml" yaml_file.write_text(invalid_handler) errors = validate_yaml_file(yaml_file) assert len(errors) >= 1 - assert any("version" in error.lower() for error in errors) + assert any("contract_version" in error.lower() for error in errors) def test_handler_contract_invalid_handler_kind(self, temp_repo): """Invalid handler_kind triggers error.""" invalid_handler = """ handler_id: compute.schema.validator name: Schema Validator -version: 1.0.0 +contract_version: + major: 1 + minor: 0 + patch: 0 descriptor: handler_kind: invalid_kind purity: pure @@ -1390,7 +1414,10 @@ def test_handler_contract_invalid_purity(self, temp_repo): invalid_handler = """ handler_id: compute.schema.validator name: Schema Validator -version: 1.0.0 +contract_version: + major: 1 + minor: 0 + patch: 0 descriptor: handler_kind: compute purity: invalid_purity @@ -1410,7 +1437,10 @@ def test_handler_contract_id_kind_mismatch(self, temp_repo): invalid_handler = """ handler_id: compute.schema.validator name: Schema Validator -version: 1.0.0 +contract_version: + major: 1 + minor: 0 + patch: 0 descriptor: handler_kind: effect purity: side_effecting @@ -1439,7 +1469,10 @@ def test_handler_contract_all_valid_kinds(self, temp_repo): valid_handler = f""" handler_id: handler.test.{kind} name: Test Handler -version: 1.0.0 +contract_version: + major: 1 + minor: 0 + patch: 0 descriptor: handler_kind: {kind} purity: {"pure" if kind == "compute" else "side_effecting"} @@ -1460,7 +1493,10 @@ def test_handler_contract_with_capability_inputs(self, temp_repo): valid_handler = """ handler_id: reducer.registration.user name: User Registration Reducer -version: 1.2.0 +contract_version: + major: 1 + minor: 2 + patch: 0 descriptor: handler_kind: reducer purity: side_effecting @@ -1495,7 +1531,10 @@ def test_handler_contract_duplicate_capability_aliases(self, temp_repo): invalid_handler = """ handler_id: reducer.registration.user name: User Registration Reducer -version: 1.2.0 +contract_version: + major: 1 + minor: 2 + patch: 0 descriptor: handler_kind: reducer purity: side_effecting @@ -1526,7 +1565,10 @@ def test_handler_contract_with_execution_constraints(self, temp_repo): valid_handler = """ handler_id: effect.database.user_repo name: User Repository -version: 2.0.0 +contract_version: + major: 2 + minor: 0 + patch: 0 descriptor: handler_kind: effect purity: side_effecting @@ -1559,7 +1601,10 @@ def test_handler_contract_with_retry_policy(self, temp_repo): valid_handler = """ handler_id: effect.api.external_service name: External Service Handler -version: 1.0.0 +contract_version: + major: 1 + minor: 0 + patch: 0 descriptor: handler_kind: effect purity: side_effecting