Skip to content

Commit 33069ac

Browse files
committed
fix: align validate endpoint and list filters with unrendered templates
- Make /controls/validate mirror create: incomplete template values validate structure only (returns 200) instead of forcing a full render that rejects missing params. Use the render preview endpoint to check renderability. - Exclude unrendered templates from list filters that reference rendered-only fields (execution, step_type, stage, tag). Unrendered templates still appear in unfiltered listings and template_backed filter. - Update validate test to expect 200 for incomplete values - Add test: validate rejects structurally invalid unrendered templates - Add test: unrendered templates excluded from rendered-field filters but included in unfiltered listings
1 parent 52604c8 commit 33069ac

2 files changed

Lines changed: 67 additions & 16 deletions

File tree

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -683,12 +683,9 @@ async def validate_control_data(
683683
Returns:
684684
ValidateControlDataResponse with success=True if valid
685685
"""
686-
if isinstance(request.data, TemplateControlInput):
687-
# Validate always attempts full rendering, even if values are incomplete.
688-
# This differs from create/update which allow unrendered storage.
689-
await _render_and_validate_template_input(request.data, db=db, enabled=True)
690-
else:
691-
await _validate_control_definition(request.data, db)
686+
# Validate mirrors create: complete template values trigger a full render,
687+
# incomplete values validate structure only (matching unrendered create).
688+
await _materialize_control_input(request.data, db=db)
692689
return ValidateControlDataResponse(success=True)
693690

694691

@@ -769,6 +766,12 @@ async def list_controls(
769766
else:
770767
query = query.where(~Control.data.has_key("template"))
771768

769+
# Filters that reference rendered-only fields exclude unrendered templates
770+
# (which lack condition/execution/scope/tags).
771+
has_rendered_filter = any(f is not None for f in (step_type, stage, execution, tag))
772+
if has_rendered_filter:
773+
query = query.where(Control.data.has_key("condition"))
774+
772775
if step_type is not None:
773776
query = query.where(
774777
or_(
@@ -817,6 +820,8 @@ async def list_controls(
817820
total_query = total_query.where(Control.data.has_key("template"))
818821
else:
819822
total_query = total_query.where(~Control.data.has_key("template"))
823+
if has_rendered_filter:
824+
total_query = total_query.where(Control.data.has_key("condition"))
820825
if step_type is not None:
821826
total_query = total_query.where(
822827
or_(

‎server/tests/test_control_templates.py‎

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -995,23 +995,38 @@ def test_template_update_defaults_enabled_to_true_when_stored_key_is_missing(
995995
}
996996

997997

998-
def test_template_validate_maps_missing_parameter_error(client: TestClient) -> None:
999-
# Given: a template-backed control payload missing a required parameter value
998+
def test_template_validate_accepts_incomplete_values_as_unrendered(
999+
client: TestClient,
1000+
) -> None:
1001+
# Given: a template payload with empty values (would be stored as unrendered)
1002+
payload = _template_payload()
1003+
payload["template_values"] = {}
1004+
1005+
# When: validating the payload
1006+
response = client.post("/api/v1/controls/validate", json={"data": payload})
1007+
1008+
# Then: validation succeeds (mirrors create behavior for unrendered templates)
1009+
assert response.status_code == 200, response.text
1010+
assert response.json()["success"] is True
1011+
1012+
1013+
def test_template_validate_rejects_structurally_invalid_unrendered(
1014+
client: TestClient,
1015+
) -> None:
1016+
# Given: a template with an undefined $param reference and empty values
10001017
payload = _template_payload()
10011018
payload["template_values"] = {}
1019+
payload["template"]["definition_template"]["condition"]["evaluator"]["config"]["extra"] = { # type: ignore[index]
1020+
"$param": "nonexistent",
1021+
}
10021022

1003-
# When: validating the payload through the public API
1023+
# When: validating the payload
10041024
response = client.post("/api/v1/controls/validate", json={"data": payload})
10051025

1006-
# Then: the error is remapped back to the missing template parameter
1026+
# Then: structural validation catches the error even without values
10071027
assert response.status_code == 422
10081028
body = response.json()
1009-
assert body["error_code"] == "TEMPLATE_PARAMETER_INVALID"
1010-
assert any(
1011-
err.get("field") == "template_values.pattern"
1012-
and err.get("parameter") == "pattern"
1013-
for err in body.get("errors", [])
1014-
)
1029+
assert body["error_code"] == "TEMPLATE_RENDER_ERROR"
10151030

10161031

10171032
def test_template_validate_succeeds_with_defaults_only_payload(client: TestClient) -> None:
@@ -1738,6 +1753,37 @@ def test_unrendered_template_shows_in_list_with_correct_flags(client: TestClient
17381753
assert unrendered["template_rendered"] is False
17391754

17401755

1756+
def test_unrendered_template_excluded_from_rendered_field_filters(
1757+
client: TestClient,
1758+
) -> None:
1759+
# Given: an unrendered template control
1760+
control_id, _ = _create_unrendered_control(client)
1761+
1762+
# When: filtering by execution (a rendered-only field)
1763+
exec_response = client.get("/api/v1/controls", params={"execution": "server"})
1764+
assert exec_response.status_code == 200
1765+
exec_ids = {c["id"] for c in exec_response.json()["controls"]}
1766+
1767+
# Then: unrendered template is excluded
1768+
assert control_id not in exec_ids
1769+
1770+
# When: filtering by step_type
1771+
step_response = client.get("/api/v1/controls", params={"step_type": "llm"})
1772+
assert step_response.status_code == 200
1773+
step_ids = {c["id"] for c in step_response.json()["controls"]}
1774+
1775+
# Then: unrendered template is excluded
1776+
assert control_id not in step_ids
1777+
1778+
# When: listing without rendered-field filters
1779+
all_response = client.get("/api/v1/controls")
1780+
assert all_response.status_code == 200
1781+
all_ids = {c["id"] for c in all_response.json()["controls"]}
1782+
1783+
# Then: unrendered template IS included in the unfiltered listing
1784+
assert control_id in all_ids
1785+
1786+
17411787
def test_unrendered_template_can_be_deleted(client: TestClient) -> None:
17421788
# Given: an unrendered template control
17431789
control_id, _ = _create_unrendered_control(client)

0 commit comments

Comments
 (0)