Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 123 additions & 41 deletions scripts/compute_contract_fingerprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -112,70 +113,134 @@
}


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.

IMPORTANT: As of OMN-1431/OMN-1436, all contracts MUST use 'contract_version',
not 'version'. Contracts using the deprecated 'version' field will be rejected.

Determines whether the contract should be validated against strict typed
models (ModelContractCompute, etc.) or the flexible ModelYamlContract.
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 -> try strict (returns True)
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.

Returns:
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', ...})
>>> validate_strict_contract({'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
>>> validate_strict_contract({'contract_version': '1.0', 'name': 'Test'})
False # Missing strict fields
>>> validate_strict_contract({'version': '1.0.0', 'name': 'Test'})
ModelOnexError # Deprecated 'version' field
>>> validate_strict_contract({'version': '1.0.0', 'contract_version': '1.0.0'})
ModelOnexError # Deprecated 'version' field (rejected even with contract_version)
"""
# 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, try strict
if has_strict_fields:
return True

# 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:
>>> is_strict_contract({'contract_version': '1.0.0', 'name': 'Test', ...})
True
>>> is_strict_contract({'contract_version': '1.0', 'name': 'Test'})
False # Missing strict fields
>>> is_strict_contract({'version': '1.0.0', 'name': 'Test'})
False # Returns False instead of raising (deprecated field)

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.

Expand All @@ -184,26 +249,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:
Expand All @@ -213,8 +281,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:
Expand All @@ -226,16 +295,20 @@ 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', ...})
>>> detect_contract_model({'node_type': 'COMPUTE_GENERIC', 'contract_version': '1.0.0', ...})
ModelContractCompute

>>> detect_contract_model({'node_type': 'CUSTOM_TYPE', 'contract_version': '1.0'})
Expand All @@ -244,31 +317,40 @@ def detect_contract_model(contract_data: dict[str, object]) -> type[BaseModel]:
>>> detect_contract_model({'name': 'test'}) # Missing node_type
ModelOnexError: Contract missing required 'node_type' field

>>> detect_contract_model({'node_type': 'COMPUTE_GENERIC', 'version': '1.0.0'})
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")

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):
# Use validate_strict_contract() for enforcement - errors propagate with details
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
Expand Down
29 changes: 26 additions & 3 deletions scripts/regenerate_fingerprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -609,6 +613,25 @@ def regenerate_fingerprint(
skip_reason="Missing node_type field (not an ONEX contract)",
)

# Check for deprecated 'version' field (OMN-1431: version -> contract_version rename)
# Files with 'version' but NOT 'contract_version' use deprecated format and should be
# migrated before fingerprinting. This prevents fingerprinting contracts that will
# fail validation with the current schema.
has_version = "version" in contract_data
has_contract_version = "contract_version" in contract_data
if has_version and not has_contract_version:
return RegenerateResult(
file_path=file_path,
old_fingerprint=None,
new_fingerprint=None,
changed=False,
skipped=True,
skip_reason=(
"Uses deprecated 'version' field instead of 'contract_version'. "
"Migrate to contract_version before regenerating fingerprint (see OMN-1431)"
),
)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
# Get existing fingerprint
old_fingerprint = contract_data.get("fingerprint")
if old_fingerprint and not isinstance(old_fingerprint, str):
Expand Down
8 changes: 2 additions & 6 deletions src/omnibase_core/contracts/contract_hash_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion src/omnibase_core/models/contracts/model_yaml_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

@field_validator("node_type", mode="before")
@classmethod
def validate_node_type(cls, value: object) -> EnumNodeType:
Expand Down
Loading
Loading