@@ -268,6 +268,8 @@ class SteeringContext(BaseModel):
268268type JsonValue = JSONValue
269269
270270_TEMPLATE_PARAMETER_NAME_RE = re .compile (r"^[a-zA-Z_][a-zA-Z0-9_]*$" )
271+ MAX_TEMPLATE_DEFINITION_DEPTH = 12
272+ MAX_TEMPLATE_DEFINITION_NODES = 1000
271273
272274
273275def _validate_re2_value (value : str , * , field_name : str ) -> str :
@@ -279,6 +281,35 @@ def _validate_re2_value(value: str, *, field_name: str) -> str:
279281 return value
280282
281283
284+ def _validate_template_definition_structure (value : JsonValue ) -> JsonValue :
285+ """Validate template definition nesting and overall size."""
286+ stack : list [tuple [JsonValue , int ]] = [(value , 1 )]
287+ node_count = 0
288+
289+ while stack :
290+ node , depth = stack .pop ()
291+ node_count += 1
292+
293+ if depth > MAX_TEMPLATE_DEFINITION_DEPTH :
294+ raise ValueError (
295+ "definition_template nesting depth exceeds maximum of "
296+ f"{ MAX_TEMPLATE_DEFINITION_DEPTH } "
297+ )
298+
299+ if node_count > MAX_TEMPLATE_DEFINITION_NODES :
300+ raise ValueError (
301+ "definition_template size exceeds maximum of "
302+ f"{ MAX_TEMPLATE_DEFINITION_NODES } JSON nodes"
303+ )
304+
305+ if isinstance (node , dict ):
306+ stack .extend ((nested_value , depth + 1 ) for nested_value in node .values ())
307+ elif isinstance (node , list ):
308+ stack .extend ((nested_value , depth + 1 ) for nested_value in node )
309+
310+ return value
311+
312+
282313class TemplateParameterBase (BaseModel ):
283314 """Base definition for a template parameter."""
284315
@@ -416,6 +447,12 @@ def validate_parameter_names(
416447 )
417448 return value
418449
450+ @field_validator ("definition_template" )
451+ @classmethod
452+ def validate_definition_template_structure (cls , value : JsonValue ) -> JsonValue :
453+ """Limit template nesting and size to keep rendering bounded."""
454+ return _validate_template_definition_structure (value )
455+
419456
420457class TemplateControlInput (BaseModel ):
421458 """Template-backed input payload for control create/update requests."""
@@ -626,7 +663,56 @@ def leaf_parts(self) -> ConditionLeafParts | None:
626663ConditionNode .model_rebuild ()
627664
628665
629- class ControlDefinition (BaseModel ):
666+ def _build_observability_identity (
667+ condition : ConditionNode ,
668+ ) -> ControlObservabilityIdentity :
669+ """Build a stable selector/evaluator identity for a condition tree."""
670+ all_evaluators : list [str ] = []
671+ all_selector_paths : list [str ] = []
672+ seen_evaluators : set [str ] = set ()
673+ seen_selector_paths : set [str ] = set ()
674+ leaf_count = 0
675+
676+ for selector , evaluator in condition .iter_leaf_parts ():
677+ leaf_count += 1
678+ selector_path = selector .path or "*"
679+
680+ if evaluator .name not in seen_evaluators :
681+ seen_evaluators .add (evaluator .name )
682+ all_evaluators .append (evaluator .name )
683+
684+ if selector_path not in seen_selector_paths :
685+ seen_selector_paths .add (selector_path )
686+ all_selector_paths .append (selector_path )
687+
688+ return ControlObservabilityIdentity (
689+ selector_path = all_selector_paths [0 ] if all_selector_paths else None ,
690+ evaluator_name = all_evaluators [0 ] if all_evaluators else None ,
691+ leaf_count = leaf_count ,
692+ all_evaluators = all_evaluators ,
693+ all_selector_paths = all_selector_paths ,
694+ )
695+
696+
697+ class _ConditionBackedControlMixin :
698+ """Shared helpers for control models backed by a condition tree."""
699+
700+ condition : ConditionNode
701+
702+ def iter_condition_leaves (self ) -> Iterator [ConditionNode ]:
703+ """Yield leaf conditions in evaluation order."""
704+ yield from self .condition .iter_leaves ()
705+
706+ def iter_condition_leaf_parts (self ) -> Iterator [ConditionLeafParts ]:
707+ """Yield leaf selector/evaluator pairs in evaluation order."""
708+ yield from self .condition .iter_leaf_parts ()
709+
710+ def observability_identity (self ) -> ControlObservabilityIdentity :
711+ """Return a deterministic representative identity for observability."""
712+ return _build_observability_identity (self .condition )
713+
714+
715+ class ControlDefinition (_ConditionBackedControlMixin , BaseModel ):
630716 """A control definition to evaluate agent interactions.
631717
632718 This model contains only the logic and configuration.
@@ -725,53 +811,12 @@ def to_template_control_input(self) -> TemplateControlInput:
725811 template_values = dict (self .template_values ),
726812 )
727813
728- def iter_condition_leaves (self ) -> Iterator [ConditionNode ]:
729- """Yield leaf conditions in evaluation order."""
730- yield from self .condition .iter_leaves ()
731-
732- def iter_condition_leaf_parts (self ) -> Iterator [ConditionLeafParts ]:
733- """Yield leaf selector/evaluator pairs in evaluation order."""
734- yield from self .condition .iter_leaf_parts ()
735-
736814 def primary_leaf (self ) -> ConditionNode | None :
737815 """Return the single leaf node when the whole condition is just one leaf."""
738816 if self .condition .is_leaf ():
739817 return self .condition
740818 return None
741819
742- def observability_identity (self ) -> ControlObservabilityIdentity :
743- """Return a deterministic representative identity for observability.
744-
745- The representative selector/evaluator comes from the first leaf in
746- evaluation order so composite trees still populate top-level event
747- dimensions. The full ordered, deduped leaf context is also returned.
748- """
749- all_evaluators : list [str ] = []
750- all_selector_paths : list [str ] = []
751- seen_evaluators : set [str ] = set ()
752- seen_selector_paths : set [str ] = set ()
753- leaf_count = 0
754-
755- for selector , evaluator in self .iter_condition_leaf_parts ():
756- leaf_count += 1
757- selector_path = selector .path or "*"
758-
759- if evaluator .name not in seen_evaluators :
760- seen_evaluators .add (evaluator .name )
761- all_evaluators .append (evaluator .name )
762-
763- if selector_path not in seen_selector_paths :
764- seen_selector_paths .add (selector_path )
765- all_selector_paths .append (selector_path )
766-
767- return ControlObservabilityIdentity (
768- selector_path = all_selector_paths [0 ] if all_selector_paths else None ,
769- evaluator_name = all_evaluators [0 ] if all_evaluators else None ,
770- leaf_count = leaf_count ,
771- all_evaluators = all_evaluators ,
772- all_selector_paths = all_selector_paths ,
773- )
774-
775820 model_config = {
776821 "json_schema_extra" : {
777822 "examples" : [
@@ -799,7 +844,7 @@ def observability_identity(self) -> ControlObservabilityIdentity:
799844 }
800845
801846
802- class ControlDefinitionRuntime (BaseModel ):
847+ class ControlDefinitionRuntime (_ConditionBackedControlMixin , BaseModel ):
803848 """Slim runtime control model that ignores template authoring metadata."""
804849
805850 model_config = ConfigDict (extra = "ignore" )
@@ -835,41 +880,6 @@ def validate_condition_constraints(self) -> Self:
835880 _validate_common_control_constraints (self .condition , self .action )
836881 return self
837882
838- def iter_condition_leaves (self ) -> Iterator [ConditionNode ]:
839- """Yield leaf conditions in evaluation order."""
840- yield from self .condition .iter_leaves ()
841-
842- def iter_condition_leaf_parts (self ) -> Iterator [ConditionLeafParts ]:
843- """Yield leaf selector/evaluator pairs in evaluation order."""
844- yield from self .condition .iter_leaf_parts ()
845-
846- def observability_identity (self ) -> ControlObservabilityIdentity :
847- """Return a deterministic representative identity for observability."""
848- all_evaluators : list [str ] = []
849- all_selector_paths : list [str ] = []
850- seen_evaluators : set [str ] = set ()
851- seen_selector_paths : set [str ] = set ()
852- leaf_count = 0
853-
854- for selector , evaluator in self .iter_condition_leaf_parts ():
855- leaf_count += 1
856- selector_path = selector .path or "*"
857-
858- if evaluator .name not in seen_evaluators :
859- seen_evaluators .add (evaluator .name )
860- all_evaluators .append (evaluator .name )
861-
862- if selector_path not in seen_selector_paths :
863- seen_selector_paths .add (selector_path )
864- all_selector_paths .append (selector_path )
865-
866- return ControlObservabilityIdentity (
867- selector_path = all_selector_paths [0 ] if all_selector_paths else None ,
868- evaluator_name = all_evaluators [0 ] if all_evaluators else None ,
869- leaf_count = leaf_count ,
870- all_evaluators = all_evaluators ,
871- all_selector_paths = all_selector_paths ,
872- )
873883
874884class EvaluatorResult (BaseModel ):
875885 """Result from a control evaluator.
0 commit comments