11from agent_control_engine import list_evaluators
2- from agent_control_models import ControlDefinition , TemplateControlInput
2+ from agent_control_models import ControlDefinition , TemplateControlInput , UnrenderedTemplateControl
33from agent_control_models .errors import ErrorCode , ValidationErrorItem
44from agent_control_models .server import (
55 AgentRef ,
3939from ..models import Agent , AgentData , Control , agent_controls , agent_policies , policy_controls
4040from ..services .condition_traversal import iter_condition_leaves_with_paths
4141from ..services .control_definitions import parse_control_definition_or_api_error
42- from ..services .control_templates import remap_template_api_error , render_template_control_input
42+ from ..services .control_templates import (
43+ can_render_template ,
44+ remap_template_api_error ,
45+ render_template_control_input ,
46+ validate_template_structure ,
47+ )
4348from ..services .evaluator_utils import (
4449 parse_evaluator_ref_full ,
4550 validate_config_against_schema ,
6065_logger = get_logger (__name__ )
6166
6267
63- def _serialize_control_definition (control_def : ControlDefinition ) -> dict [str , object ]:
64- """Serialize control data for storage while omitting null scope fields."""
65- data_json = control_def .model_dump (
68+ def _serialize_control_data (
69+ control_data : ControlDefinition | UnrenderedTemplateControl ,
70+ ) -> dict [str , object ]:
71+ """Serialize control data for JSONB storage."""
72+ data_json = control_data .model_dump (
6673 mode = "json" ,
6774 by_alias = True ,
6875 exclude_none = True ,
@@ -72,6 +79,10 @@ def _serialize_control_definition(control_def: ControlDefinition) -> dict[str, o
7279 data_json ["scope" ] = {
7380 k : v for k , v in data_json ["scope" ].items () if v is not None
7481 }
82+ # Always persist enabled explicitly so _enabled_from_stored_payload reads
83+ # the correct value (especially for unrendered templates where enabled=False).
84+ if "enabled" not in data_json :
85+ data_json ["enabled" ] = control_data .enabled
7586 return data_json
7687
7788
@@ -80,6 +91,33 @@ def _is_template_backed_payload(data: object) -> bool:
8091 return isinstance (data , dict ) and data .get ("template" ) is not None
8192
8293
94+ def _is_unrendered_template (data : object ) -> bool :
95+ """Return whether stored control JSON is an unrendered template."""
96+ return (
97+ isinstance (data , dict )
98+ and data .get ("template" ) is not None
99+ and data .get ("condition" ) is None
100+ )
101+
102+
103+ def _parse_stored_control_data (
104+ data : dict [str , object ],
105+ * ,
106+ control_name : str ,
107+ control_id : int ,
108+ ) -> ControlDefinition | UnrenderedTemplateControl :
109+ """Parse stored JSONB into the appropriate model type."""
110+ if _is_unrendered_template (data ):
111+ return UnrenderedTemplateControl .model_validate (data )
112+
113+ return parse_control_definition_or_api_error (
114+ data ,
115+ detail = f"Control '{ control_name } ' has invalid data" ,
116+ hint = f"Update the control data using PUT /api/v1/controls/{ control_id } /data." ,
117+ field_prefix = None ,
118+ )
119+
120+
83121def _enabled_from_stored_payload (data : object ) -> bool :
84122 """Return the persisted enabled flag, defaulting to True when absent."""
85123 if not isinstance (data , dict ):
@@ -135,16 +173,25 @@ async def _materialize_control_input(
135173 db : AsyncSession ,
136174 current_payload : object | None = None ,
137175 control_id : int | None = None ,
138- ) -> ControlDefinition :
139- """Resolve raw or template-backed input into a validated control definition ."""
176+ ) -> ControlDefinition | UnrenderedTemplateControl :
177+ """Resolve raw or template-backed input into a validated control or unrendered template ."""
140178 if isinstance (control_input , TemplateControlInput ):
141- enabled = (
142- True if current_payload is None else _enabled_from_stored_payload (current_payload )
143- )
144- return await _render_and_validate_template_input (
145- control_input ,
146- db = db ,
147- enabled = enabled ,
179+ if can_render_template (control_input ):
180+ enabled = (
181+ True if current_payload is None else _enabled_from_stored_payload (current_payload )
182+ )
183+ return await _render_and_validate_template_input (
184+ control_input ,
185+ db = db ,
186+ enabled = enabled ,
187+ )
188+
189+ # Incomplete values — store as unrendered template.
190+ validate_template_structure (control_input .template )
191+ return UnrenderedTemplateControl (
192+ template = control_input .template ,
193+ template_values = dict (control_input .template_values ),
194+ enabled = False ,
148195 )
149196
150197 if current_payload is not None and _is_template_backed_payload (current_payload ):
@@ -381,7 +428,7 @@ async def create_control(
381428 )
382429
383430 control_def = await _materialize_control_input (request .data , db = db )
384- control_data = _serialize_control_definition (control_def )
431+ control_data = _serialize_control_data (control_def )
385432
386433 control = Control (name = request .name , data = control_data )
387434 db .add (control )
@@ -445,11 +492,15 @@ async def get_control(
445492 )
446493
447494 # Parse data if present and non-empty
448- control_data : ControlDefinition | None = None
495+ control_data : ControlDefinition | UnrenderedTemplateControl | None = None
449496 if control .data :
450497 try :
451- control_data = ControlDefinition .model_validate (control .data )
452- except ValidationError :
498+ control_data = _parse_stored_control_data (
499+ control .data ,
500+ control_name = control .name ,
501+ control_id = control_id ,
502+ )
503+ except Exception :
453504 # Data exists but is corrupted - log and return None
454505 _logger .warning (
455506 "Control '%s' (id=%s) has corrupted data that failed validation" ,
@@ -502,13 +553,12 @@ async def get_control_data(
502553 resource_id = str (control_id ),
503554 hint = "Verify the control ID is correct and the control has been created." ,
504555 )
505- control_def = parse_control_definition_or_api_error (
556+ control_data = _parse_stored_control_data (
506557 control .data ,
507- detail = f"Control '{ control .name } ' has invalid data" ,
508- hint = "Update the control data using PUT /{control_id}/data." ,
509- field_prefix = None ,
558+ control_name = control .name ,
559+ control_id = control_id ,
510560 )
511- return GetControlDataResponse (data = control_def )
561+ return GetControlDataResponse (data = control_data )
512562
513563
514564@router .put (
@@ -559,7 +609,7 @@ async def set_control_data(
559609 control_id = control_id ,
560610 )
561611
562- control .data = _serialize_control_definition (control_def )
612+ control .data = _serialize_control_data (control_def )
563613 try :
564614 await db .commit ()
565615 except Exception :
@@ -595,7 +645,12 @@ async def validate_control_data(
595645 Returns:
596646 ValidateControlDataResponse with success=True if valid
597647 """
598- await _materialize_control_input (request .data , db = db )
648+ if isinstance (request .data , TemplateControlInput ):
649+ # Validate always attempts full rendering, even if values are incomplete.
650+ # This differs from create/update which allow unrendered storage.
651+ await _render_and_validate_template_input (request .data , db = db , enabled = True )
652+ else :
653+ await _validate_control_definition (request .data , db )
599654 return ValidateControlDataResponse (success = True )
600655
601656
@@ -809,6 +864,9 @@ async def list_controls(
809864 stages = scope .get ("stages" ),
810865 tags = data .get ("tags" , []),
811866 template_backed = "template" in data ,
867+ template_rendered = (
868+ "condition" in data if "template" in data else None
869+ ),
812870 used_by_agent = control_agent_map .get (ctrl .id ),
813871 used_by_agents_count = len (control_agent_names_map .get (ctrl .id , set ())),
814872 )
@@ -1059,6 +1117,28 @@ async def patch_control(
10591117 ],
10601118 )
10611119
1120+ if request .enabled and _is_unrendered_template (control .data ):
1121+ raise APIValidationError (
1122+ error_code = ErrorCode .VALIDATION_ERROR ,
1123+ detail = (
1124+ f"Cannot enable control '{ control .name } ': "
1125+ "unrendered template controls must be rendered first"
1126+ ),
1127+ resource = "Control" ,
1128+ hint = (
1129+ f"Provide complete parameter values via PUT /api/v1/controls/{ control_id } /data "
1130+ "to render the template before enabling."
1131+ ),
1132+ errors = [
1133+ ValidationErrorItem (
1134+ resource = "Control" ,
1135+ field = "enabled" ,
1136+ code = "unrendered_template_cannot_enable" ,
1137+ message = "Provide parameter values to render the template before enabling." ,
1138+ )
1139+ ],
1140+ )
1141+
10621142 try :
10631143 ctrl_def = ControlDefinition .model_validate (control .data )
10641144 if ctrl_def .enabled != request .enabled :
0 commit comments