Skip to content

Refactor control definition models to share runtime fields #200

@lan17

Description

@lan17

Summary

  • Deduplicate shared runtime fields and behavior between ControlDefinition and ControlDefinitionRuntime by introducing a shared Pydantic base model.
  • This supports adding future root-level control fields, such as on_evaluation_error, without repeating schema fields and validators across both models.

Motivation

  • ControlDefinition and ControlDefinitionRuntime currently duplicate the same runtime-relevant fields: description, enabled, execution, scope, condition, action, and tags.
  • They already share behavior through _ConditionBackedControlMixin, but that mixin only provides methods; it does not share Pydantic fields, defaults, validators, or schema metadata.
  • Issue Define control-level semantics for evaluator failures and timeouts #199 proposes adding a root-level control failure policy field. Adding that field to both models would deepen the existing duplication unless the models are refactored first or at the same time.

Current behavior

  • ControlDefinition and ControlDefinitionRuntime are separate Pydantic models with repeated runtime fields.
  • ControlDefinition adds template authoring fields (template, template_values) and template-specific validation.
  • ControlDefinitionRuntime ignores extra fields and is used by engine/server/SDK runtime evaluation paths.
  • Shared condition-tree helper methods live in _ConditionBackedControlMixin, but shared model fields do not.

Expected behavior

  • Runtime-relevant control fields should be declared once in a shared base model.
  • ControlDefinition should extend that base with authoring/template fields and template-specific validation.
  • ControlDefinitionRuntime should extend that base with runtime-specific config such as extra="ignore".
  • Existing validation behavior, JSON schema behavior, legacy selector/evaluator canonicalization, and runtime parsing should remain compatible unless intentionally changed.

Reproduction (if bug)

  1. Inspect models/src/agent_control_models/controls.py.
  2. Compare ControlDefinition and ControlDefinitionRuntime.
  3. Observe that the runtime fields are repeated across both models.

Proposed solution (optional)

  • Introduce a shared Pydantic base, for example ControlDefinitionBase, containing the runtime-relevant fields and common validators.
  • Keep template-only fields and template validation only on ControlDefinition.
  • Keep runtime parsing behavior, including extra="ignore", on ControlDefinitionRuntime.
  • Add tests that compare existing accepted/rejected payloads before and after the refactor.

A concrete target shape could look like this:

class ControlDefinitionBase(_ConditionBackedControlMixin, BaseModel):
    """Runtime-relevant fields shared by authored and runtime controls."""

    description: str | None = Field(None, description="Detailed description of the control")
    enabled: bool = Field(True, description="Whether this control is active")
    execution: Literal["server", "sdk"] = Field(
        ..., description="Where this control executes"
    )
    scope: ControlScope = Field(
        default_factory=ControlScope,
        description="Which steps and stages this control applies to",
    )
    condition: ConditionNode = Field(
        ...,
        description=(
            "Recursive boolean condition tree. Leaf nodes contain selector + evaluator; "
            "composite nodes contain and/or/not."
        ),
    )
    action: ControlAction = Field(..., description="What action to take when control matches")
    on_evaluation_error: Literal["fail_open", "fail_closed"] = Field(
        "fail_closed",
        description="How the control behaves when condition evaluation fails.",
    )
    tags: list[str] = Field(default_factory=list, description="Tags for categorization")

    @model_validator(mode="before")
    @classmethod
    def canonicalize_legacy_condition_shape(cls, data: Any) -> Any:
        """Accept legacy flat selector/evaluator payloads."""
        return canonicalize_control_payload(data)

    @model_validator(mode="after")
    def validate_condition_constraints(self) -> Self:
        """Validate runtime-relevant control constraints."""
        _validate_common_control_constraints(self.condition, self.action)
        return self


class ControlDefinition(ControlDefinitionBase):
    """Authored control definition, including template metadata."""

    template: TemplateDefinition | None = Field(
        None,
        description="Template metadata for template-backed controls",
    )
    template_values: dict[str, TemplateValue] | None = Field(
        None,
        description="Resolved parameter values for template-backed controls",
    )

    @model_validator(mode="after")
    def validate_template_pairing(self) -> Self:
        """Validate authoring-only template constraints."""
        has_template = self.template is not None
        has_template_values = self.template_values is not None
        if has_template != has_template_values:
            raise ValueError(
                "template and template_values must both be present or both absent"
            )
        return self


class ControlDefinitionRuntime(ControlDefinitionBase):
    """Runtime control model that ignores authoring-only metadata."""

    model_config = ConfigDict(extra="ignore")

Notes for implementation:

  • The on_evaluation_error field is shown here because issue Define control-level semantics for evaluator failures and timeouts #199 is expected to add it; if this refactor lands first, leave that field out and add it later on the shared base.
  • canonicalize_control_payload(...) can be a small module-level helper extracted from today's ControlDefinition.canonicalize_payload(...), so both authored and runtime models share legacy-shape handling without depending on a subclass.
  • The template validator should stay only on ControlDefinition, because runtime controls ignore template metadata.
  • Verify generated JSON schemas do not regress unexpectedly for API consumers.

Additional context

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions