Skip to content

Commit 058cb06

Browse files
committed
feat: support unrendered template controls
Template controls can now be created without providing parameter values. The server validates the template structure but skips rendering, storing the template metadata with enabled=false. Unrendered controls are visible in listings, attachable to agents, but excluded from evaluation. - Add UnrenderedTemplateControl model for GET response union - Create endpoint branches: complete values render, incomplete store unrendered - Update endpoint supports unrendered→rendered transition when values provided - GET endpoints discriminate via condition key presence in stored JSONB - PATCH rejects enabling unrendered templates with 422 - ControlSummary gains template_rendered field - Runtime evaluation query skips unrendered templates - Agent policy validation skips unrendered templates - 10 new server tests covering the full unrendered lifecycle
1 parent b343fce commit 058cb06

9 files changed

Lines changed: 471 additions & 34 deletions

File tree

‎models/src/agent_control_models/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
TemplateParameterBase,
4040
TemplateParameterDefinition,
4141
TemplateValue,
42+
UnrenderedTemplateControl,
4243
)
4344
from .errors import (
4445
ERROR_TITLES,
@@ -133,6 +134,7 @@
133134
"TemplateParameterDefinition",
134135
"TemplateDefinition",
135136
"TemplateControlInput",
137+
"UnrenderedTemplateControl",
136138
# Error models
137139
"ProblemDetail",
138140
"ErrorCode",

‎models/src/agent_control_models/controls.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,27 @@ class TemplateControlInput(BaseModel):
466466
)
467467

468468

469+
class UnrenderedTemplateControl(BaseModel):
470+
"""Stored state of a template control that hasn't been rendered yet.
471+
472+
An unrendered template has a template definition and possibly partial
473+
parameter values, but no concrete condition/action/execution fields.
474+
It is always ``enabled=False`` and excluded from evaluation.
475+
"""
476+
477+
template: TemplateDefinition = Field(
478+
..., description="Template definition awaiting parameter values"
479+
)
480+
template_values: dict[str, TemplateValue] = Field(
481+
default_factory=dict,
482+
description="Partial or empty parameter values",
483+
)
484+
enabled: Literal[False] = Field(
485+
False,
486+
description="Unrendered templates are always disabled",
487+
)
488+
489+
469490
class ControlAction(BaseModel):
470491
"""What to do when control matches."""
471492

‎models/src/agent_control_models/policy.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
from .base import BaseModel
2-
from .controls import ControlDefinition
2+
from .controls import ControlDefinition, UnrenderedTemplateControl
33

44

55
class Control(BaseModel):
66
"""A control with identity and configuration.
77
8-
Note: Only fully-configured controls (with valid ControlDefinition)
9-
are returned from API endpoints. Unconfigured controls are filtered out.
8+
For rendered controls (raw or template-backed), ``control`` is a
9+
``ControlDefinition``. For unrendered template controls, ``control``
10+
is an ``UnrenderedTemplateControl``.
1011
"""
1112

1213
id: int
1314
name: str
14-
control: ControlDefinition
15+
control: ControlDefinition | UnrenderedTemplateControl
1516

1617

1718
class Policy(BaseModel):

‎models/src/agent_control_models/server.py‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55

66
from .agent import Agent, StepSchema
77
from .base import BaseModel
8-
from .controls import ControlDefinition, TemplateControlInput, TemplateDefinition, TemplateValue
8+
from .controls import (
9+
ControlDefinition,
10+
TemplateControlInput,
11+
TemplateDefinition,
12+
TemplateValue,
13+
UnrenderedTemplateControl,
14+
)
915
from .policy import Control
1016

1117

@@ -302,8 +308,13 @@ class GetControlResponse(BaseModel):
302308

303309
id: int = Field(..., description="Control ID")
304310
name: str = Field(..., description="Control name")
305-
data: ControlDefinition | None = Field(
306-
None, description="Control configuration data (None if not yet configured)"
311+
data: ControlDefinition | UnrenderedTemplateControl | None = Field(
312+
None,
313+
description=(
314+
"Control configuration data. A ControlDefinition for raw/rendered "
315+
"controls, an UnrenderedTemplateControl for unrendered templates, "
316+
"or None if not yet configured."
317+
),
307318
)
308319

309320

@@ -332,7 +343,9 @@ class RemoveAgentControlResponse(BaseModel):
332343

333344

334345
class GetControlDataResponse(BaseModel):
335-
data: ControlDefinition = Field(description="Control data payload")
346+
data: ControlDefinition | UnrenderedTemplateControl = Field(
347+
description="Control data payload (rendered control or unrendered template)"
348+
)
336349

337350

338351
class SetControlDataRequest(BaseModel):
@@ -469,6 +482,14 @@ class ControlSummary(BaseModel):
469482
False,
470483
description="Whether the control was created from a template",
471484
)
485+
template_rendered: bool | None = Field(
486+
None,
487+
description=(
488+
"Whether a template-backed control has been rendered. "
489+
"True for rendered templates, False for unrendered templates, "
490+
"None for non-template controls."
491+
),
492+
)
472493
used_by_agent: AgentRef | None = Field(None, description="Agent using this control")
473494
# TODO: Follow-up with full `used_by_agents` list for richer attribution.
474495
used_by_agents_count: int = Field(

‎server/src/agent_control_server/endpoints/agents.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from agent_control_engine import list_evaluators
55
from agent_control_models.agent import Agent as APIAgent
66
from agent_control_models.agent import StepSchema
7-
from agent_control_models.controls import ControlDefinitionRuntime
7+
from agent_control_models.controls import ControlDefinition, ControlDefinitionRuntime
88
from agent_control_models.errors import ErrorCode, ValidationErrorItem
99
from agent_control_models.policy import Control as APIControl
1010
from agent_control_models.server import (
@@ -112,6 +112,14 @@ def _validate_controls_for_agent(agent: Agent, controls: list[Control]) -> list[
112112
if not control.data:
113113
continue
114114

115+
# Skip unrendered template controls — they have no evaluators to validate.
116+
if (
117+
isinstance(control.data, dict)
118+
and control.data.get("template") is not None
119+
and control.data.get("condition") is None
120+
):
121+
continue
122+
115123
try:
116124
control_definition = ControlDefinitionRuntime.model_validate(control.data)
117125
except ValidationError:
@@ -167,6 +175,8 @@ def _find_referencing_controls_for_removed_evaluators(
167175
referencing_control_set: set[tuple[str, str]] = set()
168176

169177
for ctrl in controls:
178+
if not isinstance(ctrl.control, ControlDefinition):
179+
continue # Skip unrendered template controls
170180
for _, evaluator_spec in ctrl.control.iter_condition_leaf_parts():
171181
evaluator_ref = evaluator_spec.name
172182
if ":" not in evaluator_ref:
@@ -228,6 +238,8 @@ async def _build_overwrite_evaluator_removals(
228238

229239
references_by_evaluator: dict[str, set[tuple[int, str]]] = {}
230240
for control in controls:
241+
if not isinstance(control.control, ControlDefinition):
242+
continue # Skip unrendered template controls
231243
for _, evaluator_spec in control.control.iter_condition_leaf_parts():
232244
evaluator_ref = evaluator_spec.name
233245
parsed = parse_evaluator_ref_full(evaluator_ref)

‎server/src/agent_control_server/endpoints/controls.py‎

Lines changed: 105 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from agent_control_engine import list_evaluators
2-
from agent_control_models import ControlDefinition, TemplateControlInput
2+
from agent_control_models import ControlDefinition, TemplateControlInput, UnrenderedTemplateControl
33
from agent_control_models.errors import ErrorCode, ValidationErrorItem
44
from agent_control_models.server import (
55
AgentRef,
@@ -39,7 +39,12 @@
3939
from ..models import Agent, AgentData, Control, agent_controls, agent_policies, policy_controls
4040
from ..services.condition_traversal import iter_condition_leaves_with_paths
4141
from ..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+
)
4348
from ..services.evaluator_utils import (
4449
parse_evaluator_ref_full,
4550
validate_config_against_schema,
@@ -60,9 +65,11 @@
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+
83121
def _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

Comments
 (0)