Skip to content

Commit 3e4935b

Browse files
authored
fix(server): require control data on create (#139)
## Summary - require `data` on `PUT /api/v1/controls` instead of allowing name-only shell control creation - validate the control definition before insert so invalid create requests return `422` and persist nothing - update Python SDK, TypeScript SDK, UI API types, tests, and shipped examples to use the single-request atomic create flow - keep legacy empty-control behavior only for existing rows/read paths covered by targeted compatibility tests ## Bug Explanation For a create-with-definition flow, the client did: 1. `PUT /api/v1/controls` with just `name` 2. the server inserted an empty shell control 3. `PUT /api/v1/controls/{id}/data` with the definition 4. if step 3 returned `422`, the empty shell from step 2 was still in the DB That is the concrete bug this PR fixes. Invalid create requests were not atomic, so callers could end up with persisted empty `{}` controls even though the overall create-with-definition flow had failed. One nuance: the agent association part was caller-flow dependent. The root server-side invariant failure was the persisted shell control after invalid data, and that is what this change removes. ## Testing - `cd server && uv run pytest tests/test_controls.py tests/test_controls_additional.py tests/test_controls_validation.py tests/test_agents_additional.py tests/test_auth.py tests/test_control_compatibility.py tests/test_error_handling.py tests/test_evaluation_e2e.py tests/test_evaluation_error_handling.py tests/test_init_agent.py tests/test_init_agent_conflict_mode.py tests/test_new_features.py tests/test_policies.py tests/test_policy_integration.py -q` - `make lint` - `make typecheck` - `make sdk-ts-build` - `make ui-typecheck` (still fails on pre-existing unrelated UI dependency/type issues) Closes #138
1 parent 905e83b commit 3e4935b

32 files changed

Lines changed: 407 additions & 250 deletions

‎examples/agent_control_demo/setup_controls.py‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ async def create_control(
7777
) -> int:
7878
"""Create a control with the given definition."""
7979
try:
80-
# Step 1: Create the control (just the name)
80+
# Create and validate the control atomically.
8181
response = await client.http_client.put(
8282
"/api/v1/controls",
83-
json={"name": name}
83+
json={"name": name, "data": control_definition},
8484
)
8585

8686
if response.status_code == 409:
@@ -93,13 +93,6 @@ async def create_control(
9393
response.raise_for_status()
9494
control_id = response.json().get("control_id")
9595

96-
# Step 2: Set the control data
97-
response = await client.http_client.put(
98-
f"/api/v1/controls/{control_id}/data",
99-
json={"data": control_definition}
100-
)
101-
response.raise_for_status()
102-
10396
print(f"✓ Created control '{name}' with ID: {control_id}")
10497
return control_id
10598

‎examples/deepeval/setup_controls.py‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,32 +193,38 @@ async def setup_demo(quiet: bool = False):
193193
description = control_spec["description"]
194194

195195
try:
196-
# Create control
196+
# Create and validate the control atomically.
197197
resp = await client.put(
198198
"/api/v1/controls",
199-
json={"name": control_name},
199+
json={"name": control_name, "data": definition},
200200
)
201-
if resp.status_code == 409:
201+
control_exists = resp.status_code == 409
202+
if control_exists:
202203
# Control exists, get its ID
203204
resp = await client.get("/api/v1/controls", params={"name": control_name})
204205
resp.raise_for_status()
205-
controls = resp.json().get("controls", [])
206+
controls = [
207+
control
208+
for control in resp.json().get("controls", [])
209+
if control.get("name") == control_name
210+
]
206211
if controls:
207212
control_id = controls[0]["id"]
208213
controls_updated += 1
209214
else:
215+
print(f" ❌ Could not find exact control match for '{control_name}'")
210216
continue
211217
else:
212218
resp.raise_for_status()
213219
control_id = resp.json()["control_id"]
214220
controls_created += 1
215221

216-
# Set control definition
217-
resp = await client.put(
218-
f"/api/v1/controls/{control_id}/data",
219-
json={"data": definition},
220-
)
221-
resp.raise_for_status()
222+
if control_exists:
223+
resp = await client.put(
224+
f"/api/v1/controls/{control_id}/data",
225+
json={"data": definition},
226+
)
227+
resp.raise_for_status()
222228

223229
# Associate control directly with the agent
224230
resp = await client.post(f"/api/v1/agents/{agent_name}/controls/{control_id}")

‎models/src/agent_control_models/server.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ class CreateControlRequest(BaseModel):
119119
...,
120120
description="Unique control name (letters, numbers, hyphens, underscores)",
121121
)
122+
data: ControlDefinition = Field(
123+
...,
124+
description="Control definition to validate and store during creation",
125+
)
122126

123127

124128
class InitAgentRequest(BaseModel):

‎sdks/python/ARCHITECTURE.md‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ async with agent_control.AgentControlClient() as client:
113113
- `GET /api/v1/controls/{control_id}/rules` - List control rules
114114

115115
**Functions**:
116-
- `async def create_control(client, name)` - Create a new control
116+
- `async def create_control(client, name, data)` - Create a new configured control
117117
- `async def add_rule_to_control(client, control_id, rule_id)` - Associate rule
118118
- `async def remove_rule_from_control(client, control_id, rule_id)` - Dissociate rule
119119
- `async def list_control_rules(client, control_id)` - List all rules in control
@@ -124,7 +124,25 @@ import agent_control
124124

125125
async with agent_control.AgentControlClient() as client:
126126
# Create control
127-
result = await agent_control.controls.create_control(client, "pii-protection")
127+
result = await agent_control.controls.create_control(
128+
client,
129+
"pii-protection",
130+
{
131+
"description": "PII protection",
132+
"enabled": True,
133+
"execution": "server",
134+
"scope": {"step_types": ["llm"], "stages": ["post"]},
135+
"condition": {
136+
"selector": {"path": "output"},
137+
"evaluator": {
138+
"name": "regex",
139+
"config": {"pattern": "\\\\d{3}-\\\\d{2}-\\\\d{4}", "flags": []},
140+
},
141+
},
142+
"action": {"decision": "deny"},
143+
"tags": ["security"],
144+
},
145+
)
128146
control_id = result["control_id"]
129147

130148
# Add rule to control

‎sdks/python/src/agent_control/__init__.py‎

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -935,26 +935,23 @@ async def main():
935935

936936
async def create_control(
937937
name: str,
938-
data: dict[str, Any] | ControlDefinition | None = None,
938+
data: dict[str, Any] | ControlDefinition,
939939
server_url: str | None = None,
940940
api_key: str | None = None,
941941
) -> dict[str, Any]:
942942
"""
943-
Create a new control, optionally with configuration.
944-
945-
If `data` is provided, the control is created and configured in one call.
946-
Otherwise, use `agent_control.controls.set_control_data()` to configure it later.
943+
Create a new control with configuration.
947944
948945
Args:
949946
name: Unique name for the control
950-
data: Optional control definition with a condition tree, action, scope, etc.
947+
data: Control definition with a condition tree, action, scope, etc.
951948
server_url: Optional server URL (defaults to AGENT_CONTROL_URL env var)
952949
api_key: Optional API key for authentication (defaults to AGENT_CONTROL_API_KEY env var)
953950
954951
Returns:
955952
Dictionary containing:
956953
- control_id: ID of the created control
957-
- configured: True if data was set, False if only name was created
954+
- configured: Always True because create requires data
958955
959956
Raises:
960957
httpx.HTTPError: If request fails
@@ -984,9 +981,6 @@ async def main():
984981
)
985982
print(f"Created control {result['control_id']}")
986983
987-
# Or create without config (configure later)
988-
result = await agent_control.create_control(name="my-control")
989-
990984
asyncio.run(main())
991985
"""
992986
_final_server_url = server_url or os.getenv('AGENT_CONTROL_URL') or 'http://localhost:8000'

‎sdks/python/src/agent_control/control_decorators.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -793,8 +793,7 @@ async def handle_user_input(user_message: str) -> str:
793793
794794
Server Setup (separate from agent code):
795795
1. Create controls via API:
796-
PUT /api/v1/controls {"name": "block-toxic-inputs"}
797-
PUT /api/v1/controls/{id}/data {"data": {...}}
796+
PUT /api/v1/controls {"name": "block-toxic-inputs", "data": {...}}
798797
799798
2. Create policy and add controls:
800799
PUT /api/v1/policies {"name": "safety-policy"}

‎sdks/python/src/agent_control/controls.py‎

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -118,26 +118,23 @@ async def get_control(
118118
async def create_control(
119119
client: AgentControlClient,
120120
name: str,
121-
data: dict[str, Any] | ControlDefinition | None = None,
121+
data: dict[str, Any] | ControlDefinition,
122122
) -> dict[str, Any]:
123123
"""
124-
Create a new control with a unique name, optionally with configuration.
125-
126-
If `data` is provided, the control is created and configured in one call.
127-
Otherwise, use `set_control_data()` to configure it later.
124+
Create a new control with a unique name and configuration.
128125
129126
Control names are canonicalized by the API (leading/trailing whitespace
130127
is trimmed); callers may pass trimmed names for consistency.
131128
132129
Args:
133130
client: AgentControlClient instance
134131
name: Unique name for the control
135-
data: Optional control definition (condition tree, action, scope, etc.)
132+
data: Control definition (condition tree, action, scope, etc.)
136133
137134
Returns:
138135
Dictionary containing:
139136
- control_id: ID of the created control
140-
- configured: True if data was set, False if only name was created
137+
- configured: Always True because create requires data
141138
142139
Raises:
143140
httpx.HTTPError: If request fails
@@ -147,11 +144,6 @@ async def create_control(
147144
148145
Example:
149146
async with AgentControlClient() as client:
150-
# Create without configuration (configure later)
151-
result = await create_control(client, "pii-protection")
152-
control_id = result["control_id"]
153-
154-
# Or create with configuration in one call
155147
result = await create_control(
156148
client,
157149
name="ssn-blocker",
@@ -170,22 +162,20 @@ async def create_control(
170162
)
171163
print(f"Created and configured control: {result['control_id']}")
172164
"""
173-
# Step 1: Create the control with name
165+
payload: dict[str, Any] = {"name": name}
166+
if isinstance(data, ControlDefinition):
167+
payload["data"] = data.model_dump(mode="json", exclude_none=True)
168+
else:
169+
payload["data"] = cast(dict[str, Any], data)
170+
174171
response = await client.http_client.put(
175172
"/api/v1/controls",
176-
json={"name": name}
173+
json=payload,
177174
)
178175
response.raise_for_status()
179176
result = cast(dict[str, Any], response.json())
180177

181-
# Step 2: If data provided, configure the control
182-
if data is not None:
183-
control_id = result["control_id"]
184-
await set_control_data(client, control_id, data)
185-
result["configured"] = True
186-
else:
187-
result["configured"] = False
188-
178+
result["configured"] = True
189179
return result
190180

191181

‎sdks/python/tests/conftest.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,22 @@ async def test_control(
186186
"""
187187
result = await agent_control.controls.create_control(
188188
client,
189-
f"test-control-{unique_name}"
189+
f"test-control-{unique_name}",
190+
{
191+
"description": "SDK integration test control",
192+
"enabled": True,
193+
"execution": "server",
194+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
195+
"condition": {
196+
"selector": {"path": "input"},
197+
"evaluator": {
198+
"name": "regex",
199+
"config": {"pattern": "test", "flags": []},
200+
},
201+
},
202+
"action": {"decision": "deny"},
203+
"tags": ["sdk-test"],
204+
},
190205
)
191206

192207
yield result

‎sdks/typescript/src/generated/funcs/controls-create.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@ import { Result } from "../types/fp.js";
3030
* Create a new control
3131
*
3232
* @remarks
33-
* Create a new control with a unique name and empty data.
33+
* Create a new control with a unique name.
3434
*
3535
* Controls define protection logic and can be added to policies.
36-
* Use the PUT /{control_id}/data endpoint to set control configuration.
36+
* Control data is required and is validated before anything is inserted.
3737
*
3838
* Args:
39-
* request: Control creation request with unique name
39+
* request: Control creation request with unique name and data
4040
* db: Database session (injected)
4141
*
4242
* Returns:

‎sdks/typescript/src/generated/models/create-control-request.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,22 @@
33
*/
44

55
import * as z from "zod/v4-mini";
6+
import {
7+
ControlDefinitionInput,
8+
ControlDefinitionInput$Outbound,
9+
ControlDefinitionInput$outboundSchema,
10+
} from "./control-definition-input.js";
611

712
export type CreateControlRequest = {
13+
/**
14+
* A control definition to evaluate agent interactions.
15+
*
16+
* @remarks
17+
*
18+
* This model contains only the logic and configuration.
19+
* Identity fields (id, name) are managed by the database.
20+
*/
21+
data: ControlDefinitionInput;
822
/**
923
* Unique control name (letters, numbers, hyphens, underscores)
1024
*/
@@ -13,6 +27,7 @@ export type CreateControlRequest = {
1327

1428
/** @internal */
1529
export type CreateControlRequest$Outbound = {
30+
data: ControlDefinitionInput$Outbound;
1631
name: string;
1732
};
1833

@@ -21,6 +36,7 @@ export const CreateControlRequest$outboundSchema: z.ZodMiniType<
2136
CreateControlRequest$Outbound,
2237
CreateControlRequest
2338
> = z.object({
39+
data: ControlDefinitionInput$outboundSchema,
2440
name: z.string(),
2541
});
2642

0 commit comments

Comments
 (0)