Smart Agent Teams

Database

PostgreSQL requirements, Alembic migrations, connection settings, and backup and restore.

SAT keeps all state in one PostgreSQL database: users and credentials, companies, agents, goals, projects, tasks, runs with their transcripts, approvals, routines and the audit log. The schema is managed by Alembic, and the API applies migrations itself when it starts.

Requirements

  • PostgreSQL 16. Any PostgreSQL 16 works, managed (for example Cloud SQL for PostgreSQL) or self-run; the cli-e2e CI job uses postgres:16.
  • PostgreSQL only. Migration 002 changes constraints on existing tables, which SQLite cannot do without Alembic batch mode, so the API fails to initialise on SQLite. The pytest suite uses an in-memory SQLite database but builds tables with Base.metadata.create_all, not migrations.
  • Driver. psycopg2-binary (synchronous SQLAlchemy 2). asyncpg is installed but not used.
  • Row locks. Run claims use SELECT ... FOR UPDATE SKIP LOCKED, and task numbering locks the company row. Both rely on PostgreSQL semantics.

Connection settings

SettingValueWhere
URLDATABASE_URL, for example postgresql://sat_api:<password>@10.x.x.x:5432/sat_mainA secret in production
PoolNullPool: a new connection per request, closed when the request endsdatabase/connection.py
Sessionautocommit=False, autoflush=False; one session per request through get_dbdatabase/connection.py
SQL loggingDB_ECHO=trueOff by default
NetworkRecommended: private IP only, for example over Direct VPC egress with --vpc-egress private-ranges-only on Cloud RunYour deployment

NullPool means each API request opens and closes a TCP connection to PostgreSQL. With one API instance and modest traffic this is acceptable, even on a small database instance; see Scaling before raising traffic. The SSE endpoint closes its session as soon as membership is checked, so an open event stream does not hold a database connection.

If your database has no public IP, which is recommended, you cannot connect to it from a laptop. Use your provider's export and import for data access, or a temporary job inside the private network.

Migrations at startup

On startup the API's lifespan handler calls init_db() (database/connection.py):

  1. If the database has a users table but no alembic_version table (it was created by the old create_all code), it is stamped at revision 001 first, so only newer migrations run.
  2. alembic upgrade head runs in-process.
  3. If anything fails, the error is logged as Database initialization failed, the API keeps serving, and /health answers 503 with "database": "initialization failed" until a revision starts cleanly. A deploy that gates on /health therefore refuses a release whose migrations fail.

Every instance migrates

init_db() runs on every instance start, with no lock. With one instance that is fine. With several instances starting together, they race on the same alembic upgrade. An instance that loses the race records the error and reports unhealthy until it restarts. Run migrations as a separate step before scaling out. See Scaling.

Revisions

Revisions are in apps/api/alembic/versions, numbered and linear.

RevisionFileWhat it does
001001_baseline.pyBaseline of the original schema: users, api_keys, refresh_tokens, projects, agents, sessions, session_logs, agent_tasks, deployments, system_metrics, system_health_checks.
002002_agent_company.pyThe agent-company model: creates companies, company_members, goals, tasks, task_comments, runs, approvals, routines, audit_events with their indexes. Adds company_id, reports_to_id, title, role, adapter, instructions, monthly_budget_cents, heartbeat_cron, avatar_seed to agents and makes agents.project_id nullable. Adds company_id and monthly_budget_cents to projects.
003003_password_reset_tokens.pyCreates password_reset_tokens (SHA-256 token_hash unique, user_id with ON DELETE CASCADE, expires_at, used_at, requested_ip).
004004_runner_leases_api_keys.pyAdds runs.runner_id and runs.heartbeat_at for run claims and leases, and a unique index ix_api_keys_key_hash on api_keys.key_hash for API key lookup.

Each revision has a downgrade(). Downgrades drop tables and columns, and with them their data.

Check the current revision:

cd apps/api
DATABASE_URL=postgresql://... .venv/bin/alembic current
DATABASE_URL=postgresql://... .venv/bin/alembic history

Writing a migration

Change the models

Edit apps/api/database/models.py or apps/api/database/company_models.py. models.py imports company_models, so every table is registered on Base.metadata wherever models are loaded.

Generate the revision against a local PostgreSQL at head

cd apps/api
export DATABASE_URL=postgresql://sat@127.0.0.1:5433/sat_main
.venv/bin/alembic upgrade head
.venv/bin/alembic revision --autogenerate -m "short description"

Rename the file and set revision to the next number (005) and down_revision to the previous one, to keep the numbered sequence.

Review it by hand

Autogenerate misses some changes (server defaults, renames, data moves). Keep the migration additive: new tables, nullable columns or columns with a server_default, new indexes. Rolling back to the previous image does not restore the schema, so the previous release must still work against the new schema. Split destructive changes (drops, renames, NOT NULL on existing data) into a later release, after the code that needs them is live.

Test it

.venv/bin/alembic upgrade head
.venv/bin/alembic downgrade -1 && .venv/bin/alembic upgrade head
cd ../.. && SAT_API_URL=http://localhost:8080 scripts/03-testing/cli-smoke.sh

The cli-e2e CI job starts the API against a fresh postgres:16, so a broken migration fails CI. Then regenerate the API client if response shapes changed (Development setup).

Backups

SAT has no backup job of its own; use your database's backups. Recommended:

  • Automated daily backups, kept for at least 7 days.
  • Point-in-time recovery, so you can restore to just before a bad change.
  • An on-demand backup before any migration that changes or removes data.
  • A restore tested on a scratch instance from time to time.

On Cloud SQL, for example:

gcloud sql instances describe <instance> --project <project> \
  --format 'yaml(settings.backupConfiguration)'
gcloud sql instances patch <instance> --project <project> \
  --backup-start-time 03:00 --enable-point-in-time-recovery --retained-backups-count 14
gcloud sql backups create --instance <instance> --project <project> --description "before release <sha>"

With a self-run PostgreSQL, pg_dump and pg_restore do the same job:

pg_dump --format custom --file sat_main-$(date +%Y%m%d).dump "$DATABASE_URL"
pg_restore --clean --if-exists --dbname "$DATABASE_URL" sat_main-20261005.dump

Export and restore

On Cloud SQL, logical exports go through a Cloud Storage bucket. The instance's service account needs write access on the bucket to export and read access to import.

# Export the database to a SQL file
gcloud sql export sql <instance> gs://<bucket>/sat_main-$(date +%Y%m%d).sql \
  --database sat_main --project <project>

# Import a SQL file into an instance
gcloud sql import sql <instance> gs://<bucket>/sat_main-20261005.sql \
  --database sat_main --user <database user> --project <project>

Restore a whole instance from an automated backup (this overwrites all data on the target):

gcloud sql backups list --instance <instance> --project <project>
gcloud sql backups restore <backup-id> --restore-instance <instance> \
  --backup-instance <instance> --project <project>

After a restore, restart the API (deploy the current revision again, or on Cloud Run gcloud run services update <service> --update-env-vars RESTORED_AT=<timestamp>) so it reconnects and re-runs init_db(). Runs that were running at backup time will be failed by lease expiry on the next claim or scheduler sweep.

Data growth

There is no retention or cleanup job. These tables only grow:

TableGrows withNotes
audit_eventsEvery change in every companyAppend-only. Indexed on company_id and created_at.
runsEvery runtranscript is a JSON array in the row; long runs make large rows.
refresh_tokensEvery sign-inExpired tokens are deleted for a user when that user signs in again.
password_reset_tokensEvery reset requestUsed and expired rows are kept.

On this page