Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions models/src/agent_control_models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
from .server import (
AgentRef,
AgentSummary,
CloneControlRequest,
CloneControlResponse,
ConflictMode,
ControlSummary,
ControlVersionSummary,
Expand All @@ -95,9 +97,11 @@
ListAgentsResponse,
ListControlsResponse,
ListControlVersionsResponse,
ListPublishedControlsResponse,
PaginationInfo,
PatchControlRequest,
PatchControlResponse,
PublishedControlSummary,
RenderControlTemplateRequest,
RenderControlTemplateResponse,
StepKey,
Expand Down Expand Up @@ -165,6 +169,8 @@
# Server models
"AgentRef",
"AgentSummary",
"CloneControlRequest",
"CloneControlResponse",
"ConflictMode",
"ControlVersionSummary",
"ControlSummary",
Expand All @@ -177,9 +183,11 @@
"ListAgentsResponse",
"ListControlVersionsResponse",
"ListControlsResponse",
"ListPublishedControlsResponse",
"PaginationInfo",
"PatchControlRequest",
"PatchControlResponse",
"PublishedControlSummary",
"RenderControlTemplateRequest",
"RenderControlTemplateResponse",
"StepKey",
Expand Down
2 changes: 2 additions & 0 deletions models/src/agent_control_models/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class ErrorCode(StrEnum):
CONTROL_NAME_CONFLICT = "CONTROL_NAME_CONFLICT"
EVALUATOR_NAME_CONFLICT = "EVALUATOR_NAME_CONFLICT"
CONTROL_IN_USE = "CONTROL_IN_USE"
CONTROL_PUBLISHED = "CONTROL_PUBLISHED"
CONTROL_TEMPLATE_CONFLICT = "CONTROL_TEMPLATE_CONFLICT"
EVALUATOR_IN_USE = "EVALUATOR_IN_USE"
SCHEMA_INCOMPATIBLE = "SCHEMA_INCOMPATIBLE"
Expand Down Expand Up @@ -373,6 +374,7 @@ def make_error_type(error_code: ErrorCode) -> str:
ErrorCode.CONTROL_NAME_CONFLICT: "Control Name Already Exists",
ErrorCode.EVALUATOR_NAME_CONFLICT: "Evaluator Name Conflict",
ErrorCode.CONTROL_IN_USE: "Control In Use",
ErrorCode.CONTROL_PUBLISHED: "Published Control Conflict",
ErrorCode.CONTROL_TEMPLATE_CONFLICT: "Control Template Conflict",
ErrorCode.EVALUATOR_IN_USE: "Evaluator In Use",
ErrorCode.SCHEMA_INCOMPATIBLE: "Schema Incompatible",
Expand Down
62 changes: 62 additions & 0 deletions models/src/agent_control_models/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,45 @@ class ListControlsResponse(BaseModel):
pagination: PaginationInfo = Field(..., description="Pagination metadata")


class PublishedControlSummary(BaseModel):
"""Summary of a published control in the default store."""

id: int = Field(..., description="Control ID")
name: str = Field(..., description="Control name")
description: str | None = Field(None, description="Control description")
enabled: bool = Field(True, description="Whether control is enabled")
execution: str | None = Field(None, description="'server' or 'sdk'")
step_types: list[str] | None = Field(None, description="Step types in scope")
stages: list[str] | None = Field(None, description="Evaluation stages in scope")
tags: list[str] = Field(default_factory=list, description="Control tags")
template_backed: bool = Field(
False,
description="Whether the control was created from a template",
)
template_rendered: bool | None = Field(
None,
description=(
"Whether a template-backed control has been rendered. "
"True for rendered templates, False for unrendered templates, "
"None for non-template controls."
),
)
published_at: str = Field(
...,
description="ISO 8601 timestamp when the control was published to the default store",
)


class ListPublishedControlsResponse(BaseModel):
"""Response for listing controls published in the default store."""

controls: list[PublishedControlSummary] = Field(
...,
description="List of published control summaries",
)
pagination: PaginationInfo = Field(..., description="Pagination metadata")


class ControlVersionSummary(BaseModel):
"""Summary of a single control version."""

Expand Down Expand Up @@ -585,3 +624,26 @@ class PatchControlResponse(BaseModel):
enabled: bool | None = Field(
None, description="Current enabled status (if control has data configured)"
)


class CloneControlRequest(BaseModel):
"""Request to clone a control."""

name: SlugName | None = Field(
None,
description=(
"Optional name for the cloned control. If omitted, the server generates "
"a unique copy name."
),
)


class CloneControlResponse(BaseModel):
"""Response for cloning a control."""

control_id: int = Field(..., description="Identifier of the cloned control")
name: str = Field(..., description="Name assigned to the cloned control")
cloned_control_id: int = Field(
...,
description="Identifier of the source control the clone was created from",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""add control store publication tables and clone provenance

Revision ID: 7d9c2f1a3b44
Revises: c1e9f9c4a1d2
Create Date: 2026-04-15 16:30:00.000000

"""

from __future__ import annotations

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = "7d9c2f1a3b44"
down_revision = "c1e9f9c4a1d2"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"control_stores",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)

op.create_table(
"control_stores_controls",
sa.Column("store_id", sa.Integer(), nullable=False),
sa.Column("control_id", sa.Integer(), nullable=False),
sa.Column(
"published_at",
sa.DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
),
sa.ForeignKeyConstraint(["control_id"], ["controls.id"]),
sa.ForeignKeyConstraint(["store_id"], ["control_stores.id"]),
sa.PrimaryKeyConstraint("store_id", "control_id"),
)
op.create_index(
"idx_control_stores_controls_store_published",
"control_stores_controls",
["store_id", "published_at", "control_id"],
unique=False,
)
op.create_index(
"idx_control_stores_controls_control",
"control_stores_controls",
["control_id"],
unique=False,
)

op.add_column(
"controls",
sa.Column(
"cloned_control_id",
sa.Integer(),
nullable=True,
),
)
op.create_foreign_key(
"fk_controls_cloned_control_id_controls",
"controls",
"controls",
["cloned_control_id"],
["id"],
)

control_stores = sa.table(
"control_stores",
sa.column("id", sa.Integer()),
sa.column("name", sa.String()),
)
op.get_bind().execute(
sa.insert(control_stores).values(name="default")
)


def downgrade() -> None:
op.drop_constraint(
"fk_controls_cloned_control_id_controls",
"controls",
type_="foreignkey",
)
op.drop_column("controls", "cloned_control_id")

op.drop_index(
"idx_control_stores_controls_control",
table_name="control_stores_controls",
)
op.drop_index(
"idx_control_stores_controls_store_published",
table_name="control_stores_controls",
)
op.drop_table("control_stores_controls")
op.drop_table("control_stores")
22 changes: 21 additions & 1 deletion server/src/agent_control_server/endpoints/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -1212,7 +1212,27 @@ async def add_agent_control(
"""Associate a control directly with an agent (idempotent)."""
agent = await _get_agent_or_404(agent_name, db)
control_service = ControlService(db)
control = await control_service.get_active_control_or_404(control_id)
control = await control_service.get_active_control_or_404(control_id, for_update=True)
if await control_service.is_control_published(control_id):
raise ConflictError(
error_code=ErrorCode.CONTROL_PUBLISHED,
detail=(
f"Control '{control.name}' is published in the Control Store and "
"cannot be attached directly to an agent"
),
resource="Control",
resource_id=str(control_id),
hint="Clone the published control first, then attach the clone to the agent.",
errors=[
ValidationErrorItem(
resource="Control",
field="control_id",
code="published_control_conflict",
message="Published controls must be cloned before agent association.",
value=control_id,
)
],
)

validation_errors = _validate_controls_for_agent(agent, [control])
if validation_errors:
Expand Down
Loading
Loading