Database Migrations
Last Updated: 2026-09-28 Owner: Glad-Labs/poindexter#378 Runner:src/cofounder_agent/poindexter/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 table that exists before the baseline runs is converged, not skipped
(poindexter#1097). On a compose-first install the brain seeds
app_settingsbefore the worker has migrated anything, so the table already exists when the baseline’sCREATE TABLE IF NOT EXISTSreaches it. The brain’s table used to have 8 of the 14 columns, and baseline statement #401 (idx_app_settings_is_active) failed on every such install, from May until 2026-09-28. Now, whileschema_migrationsis still empty, the baseline adds the declared columns and CHECK constraints an existing table lacks and applies its declaredNOT NULLs. It never drops, retypes, re-defaults or rewrites anything: a NULL whereNOT NULLis declared fails the run and names the column. Once any migration is recorded it converges nothing, because a missing column was then dropped on purpose, so prod never takes this path. The brain creates the declared shape itself (seed_loader.APP_SETTINGS_DDL). A squash that changesapp_settingsmust update that copy too;tests/unit/brain/test_seed_loader_app_settings_ddl.pyfails until it does. - 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. - A squash regenerates the seeds from a LIVE DB — so it re-imports operator
drift. Reconcile before merging.
0000_baseline.seeds.sqlis regenerated fold-forward from a real database, so it captures whatever prod held that day — including values that have since drifted fromsettings_defaults.py(operator-tuned rates, stale defaults the code has moved past). After any squash, runpython scripts/ci/settings_seed_value_drift_lint.pyand reconcile every key it reports before merging the squash PR. The lint reds precisely because the regen re-imported drift — that is the guard working, not a bug in the squash. Resolve each key toward the reference default (set the same value in bothsettings_defaults.pyand0000_baseline.seeds.sql), or — only for apoindexter/brain/seed_app_settings.jsonvalue that is a deliberate free-tier choice — add aTIER_POLICYentry with a reason. This is the CI check that would otherwise let the squash silently re-break the 30 keys reconciled in the 2026-07-17 pass (and the single key of poindexter#819 before it). - 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:
Soft adoption (keep old, new uses timestamp) wins on risk-adjusted
return: zero existing-DB churn, eliminates future collisions,
mechanical lex-sort still works.
Naming Convention
New migrations (after 2026-05-05)
Full example:
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/poindexter/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).
poindexter setup order. A compose-first install runs the brain’s
boot seed before any migration, so run that order too, on a second empty
database, and compare it with the first:
--compare-schema-to fails on any difference between the two schemas:
columns (with position, type, nullability and default), constraints, indexes,
triggers, sequences, views, functions and types.
Tear down with docker rm -f pg-test. CI runs both orders 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 any_-prefixed helper, and sorts lexically by filename. - For each file: skip if filename is already in
schema_migrations, otherwiseimportlib.utilit and callup(pool)orrun_migration(conn). The module is exec’d from its path and never registered insys.modules, so anything that looks its own module up there raises at import.@dataclassdoes, which is why0000_baseline.pyuses aNamedTuple. - Inserts the filename into
schema_migrationsON success only. - The first failure halts the batch (fail closed, #697): the runner logs it and re-raises, and the worker’s startup fails with it. The failed file is not recorded, so the next start retries it.
Common patterns
Add an app_settings key (not a migration)
A new key is a DEFAULTS entry in
src/cofounder_agent/poindexter/services/settings_defaults.py, with a
METADATA entry for its owner and value type
(scripts/suggest_settings_metadata.py derives one). seed_all_defaults applies
DEFAULTS on every boot with INSERT ... ON CONFLICT (key) DO NOTHING, so a
fresh install gets the row and an operator’s tuned value is never overwritten.
Don’t seed a setting from a migration: it runs once and is never re-evaluated,
so its value drifts from what fresh installs get. If 0000_baseline.seeds.sql
also seeds the key, the two values must match
(scripts/ci/settings_seed_value_drift_lint.py).
Changing an existing key’s default does not rewrite rows that already exist,
since DO NOTHING skips them. Pair the change with a migration that updates
only rows still holding the old default. The precedent is
20260928_130429_drop_org_from_research_tier1_domains.py.
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.
Retire an orphaned app_settings key
-
Prove there is no reader, from the code. Grep every tracked file:
Python, SQL, console JS, MCP servers, Grafana panels.
app_settings.last_read_atcan back that up but can’t settle it either way. Raw SQL that skipsrecord_read,site_config.all()snapshots and processes that never flush all leave a live key NULL, and so does a read that happens only on request:rate_limit_podcast_generate_per_ipis live and still NULL on prod, because its limit is read only when a request reaches the route. A stamp never expires, so a key whose last reader was deleted keeps looking read. -
Remove it from every seed source in the same commit:
DEFAULTSandMETADATAinsettings_defaults.py,0000_baseline.seeds.sql, andpoindexter/brain/seed_app_settings.json. All three insertON CONFLICT DO NOTHING, so a copy left in any one re-inserts the row after the migration deletes it.scripts/ci/settings_seed_drift_lint.pyfails CI when a source still carries a key a migration deletes. It checksDEFAULTStoo since 2026-09-28. Three more places name keys and no lint reads them, so check them by hand: thesettings_categories.pyoverride map,scripts/settings_defaults_extract.json(the #379 extract), and other hand-kept key lists in code.StartupManager’s_NON_OLLAMA_MODEL_KEYSis checked now:tests/unit/services/test_ollama_model_validator.pyfails while it names a key that no seed source carries. -
DELETE by a literal key list, never a pattern. A
LIKEsweep takes out live keys that share the name, asrate_limit_%would:down()restores the row with its seeded value. The docstring records what used to read the key, what removed that reader, and what the check found on prod. -
Pin it with a regression test. It asserts the key is absent from each
seed source and that no module reads it, and it keeps a floor of live
look-alike keys a sweep must not touch. Precedents:
test_drop_orphan_short_video_post_publish_delay.py(a key seeded by the baseline),test_drop_orphan_rate_limit_video_generate.py(a key seeded only byDEFAULTS) andtest_drop_orphan_image_model.py(a key whose live twin,image_generation_model, shares its prefix; it scans for the key as a quoted literal, so prose that names the retired key is not a reader), all intests/unit/services/migrations/.
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.
Reseed a graph_def
Every active pipeline_templates row is stamped per node with its atom’s
contract_fingerprint(). Change an atom’s AtomMeta contract (a new
FieldSpec input, a changed requires/produces) or a graph’s nodes/edges,
and the stored stamps go stale: assert_graph_def_current refuses the row at
load and that whole lane halts (poindexter#1876; glad-labs-stack#3928 halted
every Stage-2 video render on 2026-09-22). The boot self-heal does not fix
this — ensure_active_graph_defs_stamped restamps only rows carrying no
fingerprint at all — so the change needs a reseed migration:
apply_reseeds writes the RAW in-tree spec (importable in the smoke env,
which has no atom registry) and restamps it through the self-heal where the
registry imports, else on the worker’s next boot. Get the fifth field — and
each slug’s next version — from
test_graph_def_reseed_gate.py) requires the newest _RESEEDS entry
for every active graph to declare the signature the live registry produces,
so a contract change cannot merge without the migration prod needs; a
migration that writes graph_def without a signature-declaring _RESEEDS
fails the same gate. _RESEEDS must be a literal (the gate reads it with
ast.literal_eval, never by importing the migration). Refresh the per-atom
snapshot too (graph_def_contract_fingerprints.json, see
test_graph_def_contract_freshness.py) — it names which atom drifted, but
on its own it can be made green without touching prod, which is exactly how
#1876 and #3928 shipped.
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.
Prefer the plain form when the audit finds nothing that depends on the
table. An unexpected view then fails the migration and names itself,
where CASCADE would drop it without a word. The first table drop after
the baseline is
20260928_231739_drop_the_sync_metrics_table_orphaned_by_the_sync_service_removal.py.
It counts the rows first so the log says what was removed, leaves
0000_baseline.schema.sql alone (a frozen snapshot: a fresh install
creates the table there and drops it here), and its down() recreates
the structure only. test_drop_sync_metrics_table.py in
tests/unit/services/migrations/ pins those choices, compares down()
with the baseline’s column definitions, and fails if a backend module or
dashboard names the table again.
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 change an atom’s contract or a graph_def’s nodes/edges and
“fix” CI by regenerating
graph_def_contract_fingerprints.jsonalone — prod’s stored row still fails at load. Ship the reseed migration (pattern above); the reseed gate stays red until you do. - 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.