Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""add immutable out-of-box control seed identity

Revision ID: f3a1c8d7e2b4
Revises: e2b7f4a9c6d1
Create Date: 2026-07-30 12:00:00.000000

"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "f3a1c8d7e2b4"
down_revision = "e2b7f4a9c6d1"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column("controls", sa.Column("seed_source_id", sa.String(length=255), nullable=True))
op.add_column(
"controls",
sa.Column("seed_opted_out_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"idx_controls_namespace_seed_source",
"controls",
["namespace_key", "seed_source_id"],
unique=True,
postgresql_where=sa.text("seed_source_id IS NOT NULL"),
)


def downgrade() -> None:
op.drop_index("idx_controls_namespace_seed_source", table_name="controls")
op.drop_column("controls", "seed_opted_out_at")
op.drop_column("controls", "seed_source_id")
2 changes: 2 additions & 0 deletions server/src/agent_control_server/bootstrap/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Startup bootstrap helpers for server-managed defaults."""

260 changes: 260 additions & 0 deletions server/src/agent_control_server/bootstrap/out_of_box_controls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
"""Startup bootstrap for out-of-box controls.

Phase 1 provides the tooling needed to seed controls safely, but does not
register the static out-of-box control catalog yet. Phase 2 should add those
definitions to ``OUT_OF_BOX_CONTROL_TEMPLATES``.

Namespace rule:
- Standalone Agent Control seeds into ``DEFAULT_NAMESPACE_KEY``.
- Galileo-integrated Agent Control should call the same helper with
``namespace_key`` set to the Galileo ``organization_id`` carried by the
upstream auth bridge.
"""

from __future__ import annotations

from collections.abc import Collection, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Self, cast

from agent_control_models import ControlDefinition
from agent_control_models.server import SlugName
from pydantic import TypeAdapter
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from ..models import DEFAULT_NAMESPACE_KEY
from ..services.controls import ControlService

_CONTROL_NAME_UNIQUE_CONSTRAINTS = frozenset(
{
"controls_name_key",
"idx_controls_name_active",
"idx_controls_namespace_name_active",
}
)
_CONTROL_SEED_UNIQUE_CONSTRAINT = "idx_controls_namespace_seed_source"
_INITIAL_VERSION_NOTE = "Out-of-box control seed"
_SLUG_NAME_ADAPTER = TypeAdapter(SlugName)


@dataclass(frozen=True, slots=True)
class OutOfBoxControlTemplate:
"""Validated control definition plus the evaluator names it needs."""

source_id: str
name: str
control: ControlDefinition
required_evaluators: frozenset[str] = field(default_factory=frozenset)

def __post_init__(self) -> None:
object.__setattr__(self, "source_id", _SLUG_NAME_ADAPTER.validate_python(self.source_id))
object.__setattr__(self, "name", _SLUG_NAME_ADAPTER.validate_python(self.name))
if not self.required_evaluators:
required_evaluators = {
evaluator.name for _, evaluator in self.control.iter_condition_leaf_parts()
}
object.__setattr__(self, "required_evaluators", frozenset(required_evaluators))
return

object.__setattr__(self, "required_evaluators", frozenset(self.required_evaluators))

@classmethod
def from_payload(
cls,
*,
source_id: str,
name: str,
data: Mapping[str, object],
required_evaluators: Collection[str] = frozenset(),
) -> Self:
"""Build a template from raw JSON-like data and validate it immediately."""
return cls(
source_id=source_id,
name=name,
control=ControlDefinition.model_validate(data),
required_evaluators=frozenset(required_evaluators),
)


@dataclass(frozen=True, slots=True)
class SkippedOutOfBoxControl:
"""A control skipped because the current pod cannot evaluate it."""

name: str
missing_evaluators: tuple[str, ...]


@dataclass(frozen=True, slots=True)
class OutOfBoxSeedResult:
"""Summary of one bootstrap seed pass."""

created: tuple[str, ...] = ()
skipped_existing: tuple[str, ...] = ()
skipped_missing_evaluator: tuple[SkippedOutOfBoxControl, ...] = ()
skipped_conflict: tuple[str, ...] = ()

@property
def created_count(self) -> int:
"""Number of controls inserted by this seed pass."""
return len(self.created)

@property
def skipped_count(self) -> int:
"""Number of controls skipped by this seed pass."""
return (
len(self.skipped_existing)
+ len(self.skipped_missing_evaluator)
+ len(self.skipped_conflict)
)


OUT_OF_BOX_CONTROL_TEMPLATES: tuple[OutOfBoxControlTemplate, ...] = ()


def default_out_of_box_namespace_key() -> str:
"""Return the standalone namespace used for server startup seeding."""
return DEFAULT_NAMESPACE_KEY


def missing_required_evaluators(
required_evaluators: Collection[str],
available_evaluators: Collection[str],
) -> tuple[str, ...]:
"""Return required evaluator names absent from the current pod."""
missing = set(required_evaluators) - set(available_evaluators)
return tuple(sorted(missing))


async def seed_out_of_box_controls(
*,
session_factory: async_sessionmaker[AsyncSession],
namespace_key: str,
available_evaluators: Collection[str],
templates: Sequence[OutOfBoxControlTemplate] = OUT_OF_BOX_CONTROL_TEMPLATES,
) -> OutOfBoxSeedResult:
"""Create missing out-of-box controls in a namespace.

Existing seeded controls are found by immutable source ID, so customer
renames and explicit deletion opt-outs survive restarts and upgrades.
Duplicate-name and duplicate-source integrity errors are treated as benign
races with another pod and are reported as ``skipped_conflict``.
"""
if not templates:
return OutOfBoxSeedResult()

created: list[str] = []
skipped_existing: list[str] = []
skipped_missing_evaluator: list[SkippedOutOfBoxControl] = []
skipped_conflict: list[str] = []

available_evaluator_names = set(available_evaluators)
async with session_factory() as session:
for template in templates:
missing = missing_required_evaluators(
template.required_evaluators,
available_evaluator_names,
)
if missing:
skipped_missing_evaluator.append(
SkippedOutOfBoxControl(
name=template.name,
missing_evaluators=missing,
)
)
continue

outcome = await _seed_one_control(
session,
namespace_key=namespace_key,
template=template,
)
if outcome == "created":
created.append(template.name)
elif outcome == "conflict":
skipped_conflict.append(template.name)
else:
skipped_existing.append(template.name)

return OutOfBoxSeedResult(
created=tuple(created),
skipped_existing=tuple(skipped_existing),
skipped_missing_evaluator=tuple(skipped_missing_evaluator),
skipped_conflict=tuple(skipped_conflict),
)


async def _seed_one_control(
session: AsyncSession,
*,
namespace_key: str,
template: OutOfBoxControlTemplate,
) -> str:
control_service = ControlService(session)
if await control_service.seed_source_exists(
template.source_id,
namespace_key=namespace_key,
):
return "existing"
if await control_service.active_control_name_exists(template.name, namespace_key=namespace_key):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using an active, mutable name as the seed identity resurrects deleted controls and duplicates renamed ones on the next standalone startup. Could we persist an immutable seed/source ID plus an explicit opt-out tombstone, and cover delete/rename followed by reseeding?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented. Out-of-box controls now persist an immutable, namespace-scoped source_id, and deleting a seeded control records an explicit opt-out tombstone. Reseeding resolves by source ID, so renamed controls are preserved and deleted controls are not resurrected. Added regression coverage for both rename → reseed and delete → reseed scenarios.

return "existing"

control = control_service.create_control(
namespace_key=namespace_key,
name=template.name,
data=_serialize_control_data(template.control),
seed_source_id=template.source_id,
)
try:
await control_service.create_version(
control,
event_type="created",
note=_INITIAL_VERSION_NOTE,
)
await session.commit()
except IntegrityError as exc:
await session.rollback()
if _is_control_seed_conflict(exc):
return "conflict"
raise
return "created"


def _serialize_control_data(control_data: ControlDefinition) -> dict[str, object]:
data_json = control_data.model_dump(
mode="json",
by_alias=True,
exclude_none=True,
exclude_unset=True,
)
if "scope" in data_json and isinstance(data_json["scope"], dict):
data_json["scope"] = {
key: value for key, value in data_json["scope"].items() if value is not None
}
if "enabled" not in data_json:
data_json["enabled"] = control_data.enabled
return cast(dict[str, object], data_json)


def _is_control_name_conflict(error: IntegrityError) -> bool:
diag = getattr(getattr(error.orig, "diag", None), "constraint_name", None)
if diag in _CONTROL_NAME_UNIQUE_CONSTRAINTS:
return True

error_text = " ".join(
part for part in (str(error.orig), str(error)) if part and part != "None"
)
return any(name in error_text for name in _CONTROL_NAME_UNIQUE_CONSTRAINTS)


def _is_control_seed_conflict(error: IntegrityError) -> bool:
diag = getattr(getattr(error.orig, "diag", None), "constraint_name", None)
if diag == _CONTROL_SEED_UNIQUE_CONSTRAINT:
return True
if _is_control_name_conflict(error):
return True

error_text = " ".join(
part for part in (str(error.orig), str(error)) if part and part != "None"
)
return _CONTROL_SEED_UNIQUE_CONSTRAINT in error_text
7 changes: 7 additions & 0 deletions server/src/agent_control_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,13 @@ class Settings(BaseSettings):
# API settings
api_version: str = _env_alias_field("v1", "AGENT_CONTROL_API_VERSION", "API_VERSION")
api_prefix: str = _env_alias_field("/api", "AGENT_CONTROL_API_PREFIX", "API_PREFIX")
out_of_box_bootstrap_timeout_seconds: float = Field(
default=10.0,
gt=0,
validation_alias=AliasChoices(
"AGENT_CONTROL_OUT_OF_BOX_BOOTSTRAP_TIMEOUT_SECONDS",
),
)

# Prometheus metrics settings
prometheus_metrics_prefix: str = _env_alias_field(
Expand Down
29 changes: 29 additions & 0 deletions server/src/agent_control_server/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Main server application entry point."""

import asyncio
import inspect
import logging
from collections.abc import AsyncGenerator
Expand All @@ -18,6 +19,10 @@

from . import __version__ as server_version
from .auth import get_api_key_from_header
from .bootstrap.out_of_box_controls import (
default_out_of_box_namespace_key,
seed_out_of_box_controls,
)
from .config import observability_settings, settings
from .db import AsyncSessionLocal, async_engine
from .endpoints.agents import router as agent_router
Expand Down Expand Up @@ -147,6 +152,30 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
available = list(list_evaluators().keys())
logger.info(f"Evaluator discovery complete. Available evaluators: {available}")

try:
async with asyncio.timeout(settings.out_of_box_bootstrap_timeout_seconds):
seed_result = await seed_out_of_box_controls(
session_factory=AsyncSessionLocal,
namespace_key=default_out_of_box_namespace_key(),
available_evaluators=set(available),
)
if seed_result.created_count or seed_result.skipped_count:
logger.info(
"Out-of-box control bootstrap complete: created=%s "
"skipped_existing=%s skipped_missing_evaluator=%s skipped_conflict=%s",
seed_result.created_count,
len(seed_result.skipped_existing),
len(seed_result.skipped_missing_evaluator),
len(seed_result.skipped_conflict),
)
except TimeoutError:
logger.warning(
"Out-of-box control bootstrap timed out after %s seconds; continuing startup",
settings.out_of_box_bootstrap_timeout_seconds,
)
except Exception:
logger.warning("Out-of-box control bootstrap failed; continuing startup", exc_info=True)

# Initialize observability components (stored on app.state)
if observability_settings.enabled:
logger.info("Initializing observability components...")
Expand Down
12 changes: 12 additions & 0 deletions server/src/agent_control_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@ class Control(Base):
postgresql_where=text("cloned_from_control_id IS NOT NULL"),
sqlite_where=text("cloned_from_control_id IS NOT NULL"),
),
Index(
"idx_controls_namespace_seed_source",
"namespace_key",
"seed_source_id",
unique=True,
postgresql_where=text("seed_source_id IS NOT NULL"),
sqlite_where=text("seed_source_id IS NOT NULL"),
),
)

id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
Expand All @@ -195,6 +203,10 @@ class Control(Base):
cloned_from_control_id: Mapped[int | None] = mapped_column(
Integer, nullable=True
)
seed_source_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
seed_opted_out_at: Mapped[dt.datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
deleted_at: Mapped[dt.datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
Expand Down
Loading
Loading