Database Migrations
Last Updated: 2026-07-11 Owner: Glad-Labs/poindexter#378 Runner:src/cofounder_agent/services/migrations/__init__.py
This is the canonical reference for adding, naming, and running
migrations in Poindexter. If you are adding a migration, read sections
Naming Convention and
Adding a Migration before you write code.
TL;DR
- The migration history is squashed into
0000_baseline.py(+0000_baseline.schema.sql+0000_baseline.seeds.sql). The baseline has been re-rolled as the tree grew — most recently the Phase G squash (2026-07-11), which folds in every migration through20260711_*(the Phase F baseline + 42 post-baseline files; Phase F itself superseded the 2026-06-06 Phase E #1194, 2026-05-29 Phase D, and the original 2026-05-08 squashes); the docstring lists what each generation absorbed. That single file captures the whole pre-squash schema and seeds. The runner sorts lexically so0000_baseline.pyruns first (0<2); on Matt’s prod where the schema is already in place everyCREATE TABLE IF NOT EXISTSno-ops and every seedINSERT ... ON CONFLICT DO NOTHINGno-ops, leaving only the row recording the baseline as applied. New migrations use the timestamp convention. - A squash can’t drop a column — so a destructive migration may have to
survive until prod catches up.
A baseline only ever
CREATE TABLE IF NOT EXISTS, which no-ops on installs that already have the table (prod), so a baseline that merely omits a column would leave prod schema-drifted from fresh installs. Phase F therefore had to keep one surviving post-baseline migration —20260622_200222_drop_pipeline_tasks_category.py(ALTER TABLE pipeline_tasks DROP COLUMN IF EXISTS category). Phase G (2026-07-11) folded it away: once prod is verified current on the drop (every install has applied it — checked againstschema_migrations), the new baseline simply omits the column with no survivor, so Phase G is true baseline-only. Orphanschema_migrationsrows for the deleted files are harmless — the runner skips by filename and never reconciles the reverse direction. - New migrations use a UTC timestamp prefix:
YYYYMMDD_HHMMSS_<slug>.py 0000_baseline.pyis the only legacy 4-digit file left in tree — renaming it would invalidate theschema_migrationsrows of every operator’s local DB.- The runner sorts lexically — timestamp prefixes (starting with
2) always sort after the baseline (starting with0), so the relative order is preserved. - Generate one with
python scripts/new-migration.py "<slug>". - The CI lint script (
scripts/ci/migrations_lint.py) catches collisions, missingup()/run_migration(), and prefix-format drift before a PR can merge.
Why timestamp prefixes
Until 2026-05-05, every migration used a0xxx integer prefix. The
contributor’s job was to pick max(existing) + 1. That works for a
single contributor working serially. It fails the moment two PRs are
in flight at the same time:
Tonight three migrations were authored in parallel agents (#370, #373, #371) — all three claimedThe runner is filename-keyed and per-file idempotent, so the collision is mechanically harmless — both files apply, both rows land in0158because each agent independently checkedmain, saw0157was the highest, and reserved0158. — Glad-Labs/poindexter#378
schema_migrations. But it breaks the convention, makes the directory
hard to read, and there’s no guarantee the next collision will be as
benign (e.g., two migrations that BOTH try to add the same column).
Timestamp prefixes make collisions essentially impossible —
two contributors would have to start their migration files in the
same second on the same day. The runner’s lexical sort still produces
a deterministic, chronological order without any coordination.
We considered alternatives:
| Option | Why we passed |
|---|---|
services/migrations/.next lockfile | Brittle — every PR conflicts on the lockfile. |
| Pre-commit hook validating sequence | Catches collisions but doesn’t prevent them; still needs a tie-breaker. |
| Hard-cutover renaming all 0xxx files | Invalidates schema_migrations rows on every operator’s local DB. Migration to rewrite those rows is feasible but high-risk for low gain. |
Naming Convention
New migrations (after 2026-05-05)
| Element | Format | Source | Example |
|---|---|---|---|
| Date | YYYYMMDD | UTC | 20260505 |
| Separator | _ | literal | _ |
| Time | HHMMSS | UTC, 24-hour | 081530 |
| Separator | _ | literal | _ |
| Slug | [a-z0-9_]+ | what the migration does | add_x_table |
| Extension | .py | Python module | .py |
add_X_column, seed_Y,
drop_Z_table, backfill_W). Don’t put the issue number in the slug
— put it in the module docstring instead.
Legacy migrations (before 2026-05-05)
0158_*.py files documented in #378
are accepted as a historical wart — they ran cleanly on every fresh
DB, every PR-CI smoke run, and every operator install.
Sort order across both schemes
Lexical sort places legacy0xxx_ files BEFORE timestamp 2xxx_
files because 0 < 2 as a character. Within each scheme, sort is
chronological. Verified by scripts/ci/migrations_lint.py.
Adding a Migration
1. Generate the file
src/cofounder_agent/services/migrations/. The slug is
auto-lowercased and spaces become underscores.
2. Fill in up() (and down() when the change is reversible)
The runner supports two interfaces — pick whichever fits:
up() first, then falls back to
run_migration(). If neither is present, the file is logged and
skipped (won’t be recorded in schema_migrations).
3. Make it idempotent
Migrations are recorded by filename inschema_migrations and only
run once per database. But individual statements should still tolerate
re-execution — IF NOT EXISTS, ON CONFLICT DO NOTHING, etc. — so a
partial failure (the row failed to insert into schema_migrations
after the schema change applied) doesn’t break the next attempt.
4. Run the smoke test locally
- All discovered migration files apply without error.
- Each file has a corresponding
schema_migrationsrow. - No orphan rows (a row for a file that doesn’t exist).
docker rm -f pg-test. CI runs the same script
against a fresh pgvector/pgvector:pg16 service container on every
PR — see .github/workflows/migrations-smoke.yml.
5. Run the lint script
- Two NEW migrations sharing the same timestamp prefix (extremely unlikely but possible if you regenerate within the same second).
- A new migration using the legacy
0xxx_integer prefix instead of the timestamp format. - Missing
up()ANDrun_migration()(the runner would silently skip the file).
Runner mechanics
services.migrations.run_migrations(database_service) does the
following on every worker startup:
- Ensures
schema_migrations (id, name, applied_at)exists. - Lists
services/migrations/*.pyexcluding__init__.pyand sorts lexically by filename. - For each file: skip if filename is already in
schema_migrations, otherwiseimportlib.utilit and callup(pool)orrun_migration(conn). - Inserts the filename into
schema_migrationsON success only. - Per-file failures do not halt the batch — errors are logged
and the runner moves on. Returns
Falseif any failure occurred.
Common patterns
Seed an app_settings row
ON CONFLICT (key) DO NOTHING — never blow away an operator’s tuned
value. Use ON CONFLICT (key) DO UPDATE only when the description /
category changed and you want the canonical text reflected.
Per feedback_db_first_config: every tunable goes in app_settings,
not as a hardcoded constant. Per feedback_no_silent_defaults:
required settings should fail loudly at lookup time when missing —
seed them with sane defaults so that lookup never errors on a fresh
DB.
Add a column safely
IF NOT EXISTS and an explicit DEFAULT keep this safe to re-run
and avoid the row-rewrite cost on existing data.
Drop a deprecated table
CASCADE only when you’ve audited the FK references. The audit
should be in the module docstring — what was the table for, why is
it being dropped, what replaces it.
Anti-patterns
- Don’t edit a migration after it’s merged. Land a new one.
- Don’t rename a migration after it’s merged. The
schema_migrationsrow references the old name; renaming creates an orphan-row vs. unrunnable-file mismatch. - Don’t put
IF NOT EXISTSon theschema_migrationsinsert — that’s the runner’s job and it does it correctly. Your migration body shouldn’t touch the tracker table. - Don’t use Python literals for tunable behaviour. Read from
app_settingsviaSiteConfig.get()at runtime. - Don’t assume a PRIOR migration applied successfully. The runner
continues on failure; defensive
IF NOT EXISTS/IF EXISTSis cheap insurance.
Related docs
- Fresh DB setup walkthrough — end-to-end test of the full chain on a clean slate.
docs/operations/extending-poindexter.md— broader plugin / extension guide.docs/operations/migrations-audit-2026-04-27.md— historical audit (pre-#378), since removed. Some recommendations there were superseded by this doc.- Glad-Labs/poindexter#378 — the source RFC for this convention.