Skip to content
Merged
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
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,53 @@ jobs:
- name: Unit and contract tests
run: make test

postgres-integration:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
ACP_DATABASE_URL: postgresql+psycopg://control_plane:control_plane@127.0.0.1:5432/control_plane_test
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: control_plane_test
POSTGRES_PASSWORD: control_plane
POSTGRES_USER: control_plane
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U control_plane -d control_plane_test"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.11"
cache: pip
- name: Install
run: python -m pip install -e '.[dev]'
- name: Upgrade database
run: python -m alembic upgrade head
- name: Verify migration metadata
run: python -m alembic check
- name: PostgreSQL integration tests
run: make integration
- name: Rehearse migration rollback
run: |
python -m alembic downgrade base
python - <<'PY'
from agent_control_plane.postgres_store import PostgresControlPlaneStore

store = PostgresControlPlaneStore(
"postgresql+psycopg://control_plane:control_plane@127.0.0.1:5432/control_plane_test"
)
assert store.is_ready() is False
store.close()
PY
python -m alembic upgrade head

container-build:
runs-on: ubuntu-latest
timeout-minutes: 10
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ for public contracts once they are declared stable.
- Risk-based review and delivery policy.
- Agent registration and lifecycle status APIs with optimistic revision checks.
- Human approval queue with single-decision enforcement and append-only audit events.
- PostgreSQL system of record with transactional audit writes, Alembic migrations, readiness
checks, and database-level audit mutation protection.
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ WORKDIR /app

RUN groupadd --system app && useradd --system --gid app app

COPY pyproject.toml README.md ./
COPY pyproject.toml README.md alembic.ini ./
COPY src ./src
COPY migrations ./migrations
RUN python -m pip install --no-cache-dir .

USER app
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install format lint type unit smoke test audit check run
.PHONY: install format lint type unit smoke integration test audit check migrate run

install:
python -m pip install -e '.[dev]'
Expand All @@ -20,6 +20,9 @@ unit:
smoke:
python -m pytest tests/smoke -m smoke

integration:
python -m pytest tests/integration -m integration

test:
python -m pytest --cov=agent_control_plane --cov-report=term-missing

Expand All @@ -28,5 +31,8 @@ audit:

check: lint type test

migrate:
python -m alembic upgrade head

run:
python -m agent_control_plane
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ make check
make run
```

The API is then available at `http://127.0.0.1:8000`. Important endpoints:
Without `ACP_DATABASE_URL`, the service uses its process-local in-memory adapter. The API is
then available at `http://127.0.0.1:8000`. Important endpoints:

- `GET /health/live`
- `GET /health/ready`
Expand All @@ -48,12 +49,21 @@ The API is then available at `http://127.0.0.1:8000`. Important endpoints:
- `GET /v1/audit-events`
- `GET /docs`

Container execution:
Persistent local execution starts PostgreSQL, runs migrations, and then starts the API:

```bash
docker compose up --build
```

For an externally managed PostgreSQL database, set a `postgresql+psycopg://` URL and migrate
before starting the service:

```bash
export ACP_DATABASE_URL='postgresql+psycopg://user:password@host/database'
make migrate
make run
```

## Delivery policy

Every change merged to `main` goes through a pull request, review, and required fast checks.
Expand All @@ -67,9 +77,12 @@ The first governance loop is available: register an agent, activate or pause it
revision checks, request and decide human approval, and inspect the resulting audit events.
Public API compatibility starts with the `v1` schema.

The bundled store is intentionally in-memory and intended for development and evaluation. Data
does not survive a process restart and must not be treated as a production system of record.
PostgreSQL persistence, authenticated actor identity, and durable workflows remain planned.
PostgreSQL is available as the durable system of record. Agent changes, approval changes, and
their audit events commit atomically; a database trigger rejects audit updates, deletion, and
truncation. Readiness fails when the configured database is unavailable or not migrated.

The in-memory adapter remains available for development and evaluation only. Authenticated
actor identity, request idempotency, backup automation, and durable workflows remain planned.

## License

Expand Down
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ changes require the high-risk review path described in `docs/QUALITY_GATES.md`.

Never place API keys, customer traces, prompts, memories, production data, or credentials in
the repository or test fixtures.

The credentials in `compose.yaml` are fixed development-only values. Production deployments
must inject a separate database URL through secret management, restrict the application role,
encrypt connections, and run backup/restore exercises before storing customer data.
37 changes: 37 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[alembic]
script_location = migrations
prepend_sys_path = .

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
35 changes: 34 additions & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: control_plane
POSTGRES_PASSWORD: control_plane
POSTGRES_USER: control_plane
ports:
- "${ACP_POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U control_plane -d control_plane"]
interval: 5s
timeout: 3s
retries: 10

migrate:
build: .
command: ["alembic", "upgrade", "head"]
environment:
ACP_DATABASE_URL: postgresql+psycopg://control_plane:control_plane@postgres:5432/control_plane
depends_on:
postgres:
condition: service_healthy

control-plane:
build: .
environment:
ACP_DATABASE_URL: postgresql+psycopg://control_plane:control_plane@postgres:5432/control_plane
ports:
- "8000:8000"
- "${ACP_HTTP_PORT:-8000}:8000"
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test:
- CMD
Expand All @@ -15,3 +45,6 @@ services:
timeout: 3s
retries: 3
start_period: 5s

volumes:
postgres-data:
19 changes: 12 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,21 @@ Control Plane API
`- version promotion (planned)
```

The current code implements the API shell, the first versioned contract, and an in-memory
governance loop. The storage protocol is owned by the control plane so PostgreSQL can replace
the development adapter without leaking database types into the public API. PostgreSQL becomes
the source of truth when persistence is introduced. Vector databases remain derived indexes,
not authoritative stores.
The current code implements the API shell, the first versioned contract, and a governance loop
with in-memory and PostgreSQL adapters. The storage protocol is owned by the control plane, so
database types do not leak into the public API. PostgreSQL is the durable source of truth;
vector databases remain derived indexes, not authoritative stores.

State changes use an expected revision to reject stale writers. Only active agents can request
approval. Approval requests are single-decision records: an approved or rejected request cannot
be overwritten. Audit events are append-only within the store and returned newest first.
Authentication and durable audit retention are required before production use.
be overwritten. State changes and their audit events share one database transaction. PostgreSQL
row locks serialize competing status and decision operations, while conditional updates provide
a second conflict check. A database trigger blocks audit mutation and removal. Events are
returned newest first.

Alembic owns schema versioning. Deployments run migrations as a separate step before the API;
readiness stays unavailable when the schema is missing. Authentication, backup policy, and
retention enforcement are required before production use.

## Adapter policy

Expand Down
6 changes: 5 additions & 1 deletion docs/QUALITY_GATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ applies to changes merged into `main`, not to every local edit or experimental c
| Lane | Target time | Required evidence | When it runs |
| --- | ---: | --- | --- |
| Local | under 60 seconds | focused tests, formatter | while developing |
| Pull request | under 5 minutes | lint, types, unit tests, smoke tests, container build | every PR |
| Pull request | under 5 minutes | lint, types, unit tests, PostgreSQL integration, smoke tests, container build | every PR |
| Main | under 15 minutes | clean rebuild and complete current suite | every merge |
| Scheduled | time-boxed | dependency audit and supported Python versions | weekly |
| Release | risk-based | migration, rollback, security, and scenario tests | before release |
Expand All @@ -30,6 +30,9 @@ Smoke tests prove that the packaged service starts conceptually, reports readine
valid public contract, and rejects invalid input. They do not replace behavior, integration,
load, recovery, or security tests.

Database changes additionally require upgrade, integration, downgrade, and re-upgrade evidence
against the supported PostgreSQL version. Migration rehearsal uses disposable data only.

## Dependency maintenance

Dependency pull requests must identify a compatibility, security, or reproducibility benefit.
Expand All @@ -46,6 +49,7 @@ Configure a GitHub ruleset for `main` with:
- stale approvals dismissed after new code is pushed;
- conversation resolution required;
- `fast-gate` and `container-build` required;
- `postgres-integration` required for database changes;
- force pushes and deletion blocked;
- administrators subject to the same rules.

Expand Down
4 changes: 2 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ Roadmap items advance only when tied to a validated user problem and an acceptan
- Run replay and failure classification.
- Tool-call schema validation and risk policy.
- [x] In-memory human approval queue and append-only audit contract.
- PostgreSQL-backed approval and immutable audit persistence.
- [x] PostgreSQL-backed approval and immutable audit persistence.
- Offline evaluation datasets and version promotion gates.

## Durable operations

- PostgreSQL system of record.
- [x] PostgreSQL system of record with reversible migrations.
- Durable workflow adapter for cross-day tasks.
- Idempotency, retry, compensation, and dead-letter handling.
- Backup, restore, tenant isolation, and disaster exercises.
Expand Down
59 changes: 59 additions & 0 deletions migrations/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Alembic migration environment."""

import os
from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config, pool

from agent_control_plane.bootstrap import DATABASE_URL_ENV
from agent_control_plane.db_schema import metadata

config = context.config

if config.config_file_name is not None:
fileConfig(config.config_file_name)

target_metadata = metadata


def database_url() -> str:
value = os.environ.get(DATABASE_URL_ENV)
if not value:
raise RuntimeError(f"{DATABASE_URL_ENV} is required for database migrations")
return value


def run_migrations_offline() -> None:
context.configure(
url=database_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)

with context.begin_transaction():
context.run_migrations()


def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section) or {}
configuration["sqlalchemy.url"] = database_url()
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)

with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)

with context.begin_transaction():
context.run_migrations()


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
25 changes: 25 additions & 0 deletions migrations/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""

from collections.abc import Sequence

from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}


def upgrade() -> None:
${upgrades if upgrades else "pass"}


def downgrade() -> None:
${downgrades if downgrades else "pass"}
Loading