Alembic migrations
_shared/alembic-migrations
| Kind | shared |
|---|---|
| Applies to | backend |
| State | written |
| Maturity | prod |
| Reusable | yes |
| Stack | alembic-1.16, sqlalchemy-2.0, postgres-14-to-16, python-3.13 |
| Depends on | _shared/fastapi-project-layout |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-11 |
| Also measured on |
|
| Version | 1 |
Purpose
Every table a service owns is born in a migration file, applied once per environment before the new code starts, and the running code refuses to boot against a database that is not at the revision it was written for. The invariant that makes it trustworthy is that the migration chain is the only writer of schema: no create_all() outside pytest, no hand-patcher at boot, no DDL typed into a live database, and exactly one head at every commit, so alembic upgrade head means one thing on every machine.
This foundation fixes, for every backend recipe, how a migration file is named and shaped, what it may and may not do, how the boot check and the deploy hook use it, and how a second service (Prima) keeps its own chain from ever touching the first one's.
When to use
Include it from every kind: backend recipe whose Data model section names a table, a column, a constraint or an index, and from _shared/fastapi-project-layout (S4), whose boot sequence calls the head check. Read it before writing alembic revision, before adding a value to a Postgres enum, before touching a table the baseline created, and before answering "why does the container crash-loop after the deploy". Trigger phrases: "add a column", "new table", "the enum needs a value", "alembic heads shows two", "stamp or upgrade", "migrate before or after the swap", "which database is this pointed at".
Do not use it for data backfills that belong to a job (those are _shared/scheduler-and-background-work, S12), for the test schema (pytest builds it from the models, see _shared/test-layout, S9), or for a client site on the Cloudflare stack, which has no Alembic: its D1 schema is EmDash's and is covered by _shared/astro-site-skeleton (S3).
Stack assumptions
Two chains exist and are measured here. They share the tool and the rules; they share nothing else.
| Chain | Repo (branch, commit) | Version table | Revisions | Head |
|---|---|---|---|---|
| Labs | alchemy_labs (d1a-token-source, fd8558f; the backend tree is unchanged from feat/service-desk b1089b6) | alembic_version | 91 files under backend/migrations/versions/, one base, one head | subscription_cancel_otp_001 |
| Prima (D3: the reference for the PrimaAPI modules) | prima (feature/per-model-pricing, cd2c248) | prima_alembic_version | 5 files under migrations/versions/, linear | prima_sparks_buckets_001 |
Versions, measured 2026-09-11: the Labs backend virtualenv resolves alembic 1.16.5 and SQLAlchemy 2.0.43 on Python 3.13.7; Prima's resolves alembic 1.14.0 and SQLAlchemy 2.0.36. CI runs the Labs chain on postgres:15 (.github/workflows/ci-cd.yml, job migration-check); the local test Postgres is postgres:14 (docker-compose.test.yml, service postgres-test, host port 5434); Prima's compose file runs postgres:16-alpine on 5433 (docker-compose.yml line 16). What is measured is narrower than that list: both chains build from empty on the local postgres:14 (2026-09-11, Acceptance checks 4 and 8); the CI job on postgres:15 has failed at dependency install on every recorded run of the workflow, six runs from 2026-06-01 to 2026-09-09 (Gotchas); no run of either chain on postgres:16 is recorded here.
Foundations assumed: S4 for where core/database.py and core/config.py sit (declared in depends_on, the way S5 and S7 declare it; S4 includes this recipe by reference for its boot check and does not list it back, so the graph stays acyclic); S9 for the scratch databases the acceptance checks use (S9 depends on this recipe, so it is not listed here). _shared/workspace-scoping (S6) owns the tenant column rules a migration implements; this recipe only says how the column arrives.
Environment names and fail behaviour:
- Labs:
DATABASE_URLwins when set; otherwiseDB_HOST,DB_PORT,DB_NAME,DB_USER,DB_PASSWORDare assembled into it (core/config.pylines 357 to 371,DB_DSNaliased toDATABASE_URL).migrations/env.pyline 92 overridesalembic.ini'ssqlalchemy.urlwithsettings.SYNC_DATABASE_URL, so the ini file's%(DB_USER)splaceholders (line 36) are never read. Missing values fall back to the config defaults (alocalhosthost on 5432 and a fixed database name,core/config.pylines 219 to 221), which is the wrong database rather than no database; the acceptance checks below always export the URL explicitly, and anupgradetyped with the variable unset on a developer laptop runs against whatever local database carries that name. - Labs, one revision only:
WEBAUTHN_RP_ID_BACKFILL.20260803_0001_webauthn_rp_id.pyrefuses to run when the relying-party id it would backfill resolves to a development placeholder (localhost,127.0.0.1,::1,example.com,test; the set at line 67, the resolution at lines 70 to 108) unless the operator names the value explicitly. Measured 2026-09-11 on an empty scratch database: without the variable the chain stops at that revision withRuntimeError: Refusing to backfill webauthn_credentials.rp_id with 'localhost'; withWEBAUTHN_RP_ID_BACKFILL=localhostit completes. The rendering harness sets it (tooling/render/labs-local.shline 105), the two per-seam test folders document it in their docstrings, and CI'smigration-checkjob does not set it (see Gotchas). - Prima:
PRIMA_DATABASE_URL, read throughprima.config.load_settings()and nothing else.alembic.inideliberately carries nosqlalchemy.url(its own comment, lines 5 to 9), so a migration cannot be pointed at a different database than the application by editing an ini file, and it cannot run at all with the variable unset.
Data model
The chain's own state is one row per head in the version table; everything else in this section is the shape of a revision file.
Where files live and what they are called. Labs: backend/alembic.ini sets script_location = migrations (line 5), version_locations = %(here)s/migrations/versions (27), timezone = UTC (14) and truncate_slug_length = 40 (17), with a file_template (8) that would stamp YYYYMMDD_HHMM_<rev>_<slug>. The template is what alembic revision would emit; the chain as measured carries three generations of names, and only the last is the rule for new files:
| Generation | Example file | Revision id | At fd8558f |
|---|---|---|---|
| Alembic default with the ini template (two from 2025-11-20, two merge revisions from 2026-05-04 and 2026-08-02) | 20251120_1857_425a04794b53_update_onboarding_steps_add_form_.py | 12-hex hash | 4 files, legacy |
Dated slug, a date and a letter or a bare date (2025-11 to 2026-05), plus the three undated 2026-08 files (baseline_prod_schema_001.py, deferred_fk_phantom_tables_001.py, folded_pending_migs_001.py) | 20260408b_add_workspace_id_to_outreach.py | assorted strings | 48 files (45 dated, 3 undated), legacy |
Dated, numbered, semantic (the rule from 2026-06-09; two earlier files carry an HHMM stamp in the counter slot, 20251121_0815_manual_project_tables.py with the suffix-less id manual_project_tables and 20260512_0227_review_original_snapshot.py) | 20260911_0002_subscription_cancel_otp.py | subscription_cancel_otp_001 | 39 files, the rule |
The rule: the file is YYYYMMDD_NNNN_<slug>.py (the date the revision was written, a four-digit counter that restarts each day, a snake-case slug of at most 40 characters); the revision id is a string that reads as the slug plus a three-digit suffix (desk_estimate_001, leadsource_booking_001), never a hash, so alembic history, the boot log and an incident message name the change and not a checksum. Prima follows the same rule from its third file on (20260803_0001_auth_seam.py is prima_auth_001); its first two, 0001_prima_meter.py and 0002_prima_credits.py, predate the rule and carry prima_meter_0001 and prima_credits_0002. Prima's ids all start with prima_; four Labs ids do too (prima_key_sync_001, prima_credit_grants_001, prima_magic_link_tpl_001, prima_flex_removal_001, the Labs half of the Prima seams and their retirement), which is one reason the guard under Security & tenancy checks tables and not id prefixes.
The alembic.ini a new service starts from (Labs lines 1 to 12 and Prima's at cd2c248, less what each carries for its own history): script_location = migrations, prepend_sys_path = ., version_path_separator = os, timezone = UTC, truncate_slug_length = 40, the logger sections, and no sqlalchemy.url (Prima's comment at lines 5 to 9: the URL comes from the settings loader in env.py, so an ini edit cannot point a migration at another database). No file_template: revision files are written by hand in the shape above, and a generator run used as a draft (Application logic step 2) is renamed and re-id'd before it is committed. Labs' file_template (line 8) and its sqlalchemy.url placeholder (line 36, never read) are the reference's history, not the rule.
The file body. The rule for a new file, with 20260911_0002_subscription_cancel_otp.py (lines 1 to 11) as the model: a module docstring whose first line is the change in one sentence, then Revision ID:, Revises:, Create Date: (the Alembic header), then one paragraph on why the change exists and one sentence that says where the test schema comes from (Tests use Base.metadata.create_all() (models/commerce_models.py); this migration is for the live prod/staging schema., lines 8 to 10). Then revision, down_revision, branch_labels = None, depends_on = None, an upgrade() and a real downgrade(). Measured 2026-09-11 on the twelve Labs revisions dated 2026-09-01 or later: three carry the test-schema sentence (20260907_0001_leadsource_add_booking.py, 20260911_0001_workspace_documents.py, 20260911_0002_subscription_cancel_otp.py); the other nine put the header last in the docstring and say nothing about the test schema (20260904_0001_service_desk_core.py lines 1 to 14 is typical). The shape is the rule from here on, not a description of the tree.
What a revision may contain, in order of preference:
op.create_table(...)with every column typed,nullablestated on every column,server_defaultfor anything the running old code will insert without naming (sa.text("gen_random_uuid()")for UUID primary keys,sa.func.now()forcreated_at,"0"for counters), indexes created by name in the same revision (ix_subscription_cancel_otps_email, lines 35 to 37 of the file above), including partial unique indexes, which theopAPI expresses asop.create_index(..., unique=True, postgresql_where=sa.text(...))(20260905_0001_desk_domain_login.pylines 181 to 187, theWHERE verified_at IS NOT NULL AND disabled_at IS NULLindex thattests/test_desk_domain_login_schema.pypins).op.add_column(...)as nullable, or with aserver_default, so the old container that is still serving during the migration keeps inserting (20260802_0002_surface_grants.pylines 76 to 79:magic_links.surfacearrivesnullable=Falseonly because it carriesserver_default='labs').- A nullable column, a backfill
UPDATE, thenop.alter_column(..., nullable=False)in that order inside one revision when the column must end up NOT NULL over existing rows (20260803_0001_webauthn_rp_id.pylines 128 to 136). - A string column plus
sa.CheckConstraint("<col> IN (...)", name="ck_<table>_<col>")for an enumerated value set (user_surface_grants.surface,ck_usg_surface, surface_grants lines 65 to 67; four CHECKs in20260808_0001_sparks_billing.py; three in20260904_0001_service_desk_core.py). Measured 2026-09-11: no revision dated after20260710_0001declaressa.Enum,postgresql.ENUMorCREATE TYPE; the 54 Postgres enum types in the schema at head are all baseline-era. Adding a value to one of those isop.execute("ALTER TYPE <name> ADD VALUE IF NOT EXISTS '<value>'")in a revision that inserts no row using the value (20260907_0001_leadsource_add_booking.py, whose docstring records why that is safe inside Alembic's transaction on the Postgres versions in use); itsdowngrade()is a documented no-op because Postgres cannot drop an enum value. op.execute(...)for what theopAPI cannot express: the enumADD VALUEin rule 4, a backfillUPDATE(20260905_0001_desk_domain_login.pyline 230, that file's onlyop.execute), row-level security policies (20260504d_workspace_rls_policies.py, sixop.executecalls). Raw DDL loses thedowngrade()symmetry theopcalls give for free, so it is the last choice, not the first.- The
_phantom_guardhelpers (add_column_if_missing,create_index_if_missing,create_foreign_key_if_missingand three more,backend/migrations/_phantom_guard.py) only in a revision that alters a table the baseline created in its final shape; the module's docstring says they are not a general escape hatch, and a revision on a table born in the chain uses the plainopcalls so a wrong assumption fails loudly.
What is NOT in a revision: a sa.Enum(...) declaration (rule 4); an ondelete='CASCADE' on any Prima table (Security & tenancy); a raw COMMIT (one 2025-11-20 Labs file does it and it is the reason the chain is not atomic, see Gotchas); an import of app_fastapi or a service module (a revision that imports the application runs application code against a database mid-DDL; the one revision that imports core.config does so inside a function, as a guarded fallback, line 80 of the WebAuthn file); a data migration whose result depends on the environment it runs in without refusing when that environment is unset (the WebAuthn file is the pattern).
The naming convention the metadata carries (core/database.py lines 21 to 30): ix_<column label>, uq_<table>_<column>, ck_<table>_<name>, fk_<table>_<column>_<referred table>, pk_<table>. The convention reaches a revision because migrations/env.py passes target_metadata to context.configure, and Alembic applies it to op.create_table and the constraint operations. It does not treat every name the same way, and this is where a hand-written revision goes wrong (measured 2026-09-12 with SQLAlchemy 2.0.43 and Alembic 1.16.5, the Labs pins): a template that carries %(constraint_name)s (only ck here) prefixes an explicit short name, so sa.CheckConstraint(..., name="status_valid") on outbox_events is stored as ck_outbox_events_status_valid; a template without it (uq, pk, fk, ix) applies only to an unnamed constraint, so sa.UniqueConstraint("note_id") is stored as uq_notes_note_id while sa.UniqueConstraint("note_id", name="note_id") is stored as note_id, unprefixed. The rule for a revision: give a CHECK its short name and give nothing else a name (name=None), or write the full composed name wrapped so the convention leaves it alone (op.f("uq_notes_note_id") in a revision, sqlalchemy.sql.naming.conv(...) in a model). The stored names are then the ones a later op.drop_constraint passes without inspecting the database; S11's Gotcha records the CHECK half from the outbox tables. Prima names its CHECKs ck_prima_<table>_<what> (ck_prima_api_keys_scopes_nonempty, ck_prima_revocation_cursor_singleton, 20260803_0001_auth_seam.py lines 172 and 215) and its foreign keys fk_prima_<table>_<target> (165 to 200).
Prima's chain, in numbers (measured 2026-09-11 by running it on an empty scratch database): 5 revisions, 12 tables including prima_alembic_version, 5 foreign keys all ON DELETE RESTRICT, 0 with CASCADE, 50 CHECK constraints named ck_*. Every scalar character column carries a token CHECK, ck_<table>_<col>_token: the metered tables through prima/content_free.py (token_check, token_check_name, lines 221 to 227), which migrations/env.py runs by importing prima.models (lines 44 to 48); the identity tables on AuthBase, which that check does not govern, through _token_checks() in 20260803_0001_auth_seam.py (lines 64 to 76). That is how "content-free" is a property of the schema and not of the code that writes to it.
Labs' chain, in numbers (same method, same day): 91 revisions, 201 tables at head including alembic_version, 54 enum types. The base, baseline_prod_schema_001.py (4165 lines, down_revision = None), creates the roughly 100 tables that create_all() had made over the years and no migration had ever recorded, as verbatim pg_dump --schema-only bodies of the staging database taken 2026-08-02; deferred_fk_phantom_tables_001 adds their foreign keys at the end of the chain once every referenced table exists; folded_pending_migs_001 carries what the retired boot-time hand-patcher used to do. Three merge revisions exist (0aad13a9c18d, two parents, 2026-05-04; 422b303029b6, three parents, and flex_removal_converge_001, two, both from the 2026-08-02 convergence); a new one is the last resort, not the first response (Prima's chain test says why: two branches written blind of each other have an order, and a merge revision hides it).
API surface
No HTTP route. The contract is the command line and the boot sequence:
alembic headsprints one line per head; the CI gate and the two chain tests assert exactly one.alembic currentprints the row in the version table, and after any failedupgradeit is the first command to run (Gotchas).alembic historylists the chain by revision id, which is why ids are semantic.alembic upgrade headis issued by exactly one caller per environment:scripts/migrate.shbefore the container tag is swapped (Deploy & ops), by hand from a one-off container when a migration shipped without it, by the rendering harness and the per-seam test folders against a scratch database, and by CI against a throwawaypostgres:15. It is never in the imageCMD(the script's own header, lines 24 to 30, on N replicas racing the same DDL).alembic downgrade -1is real on both chains: CI runsupgrade head,downgrade -1,upgrade head(ci-cd.ymllines 301 to 303) on every push to the listed branches, so the newest revision'sdowngrade()is exercised once before it can ship.alembic stamp <rev>is not a routine command on either chain. The wiki records one use on 2026-08-02, when the staging database (built bycreate_all()and hand DDL, version table empty) was stamped at the baseline and then upgraded; ADR-0029 records why the same trick could not be applied to the production database of the day and what replaced it (Gotchas). A new service that follows this recipe from its first table never needsstamp.core.database.verify_schema_at_head()(Labs, lines 250 to 282): reads the code's heads throughScriptDirectoryand the database'salembic_versionrows, raisesRuntimeErrorwhen the table is missing (Database has no alembic_version table) or when the two sets differ (Database is not at Alembic head. Current revision(s): {...}; code expects: {...}).init_db()(lines 289 to 331) calls it on every boot that is not pytest and re-raises, so the lifespan fails and the process exits. Under pytestinit_db()callsBase.metadata.create_all()at most once per process instead (S9 says why).- Prima has no boot-time head check at cd2c248; its guard runs on the migration side (Security & tenancy). The chain tests are the gate.
Application logic
How a schema change ships, in commit order:
- Change the model in
models/<area>_model.py(Labs) orprima/models/andprima/auth/models.py(Prima). Register it: Labs'models/__init__.pyimports every model class soBase.metadatais complete for the pytestcreate_all()path (its comments name three tables that were missing from it and failed hermetic tests withrelation does not exist:scheduled_messages, the service-desk tables,user_surface_grants); Prima'smigrations/env.pyimportsprima.models(line 48, which also runs the content-free schema check) andprima.auth.models(60) and lists both metadatas intarget_metadata(62). - Write the revision by hand, in the shape from Data model. Do not run
alembic revision --autogenerateon Labs and commit what it prints: measured 2026-09-11, the metadata thatmigrations/env.py's import list (lines 28 to 79) registers has 216 tables, the migrated database has 200 plus the version table, and the difference is 21 tables the running app never imports (files,file_versions,ml_predictions,user_psychographicsand 17 more) plus 5 tables the chain creates and no model describes (brand_essence_drafts,knowledge_embeddings,platform_activity_buckets,primalayered_job_budgets,primalayered_spend_policy); autogenerate proposes creating the 21 and dropping the 5.make revision m='...'on Prima runs autogenerate against two small metadatas and is usable as a draft; its output still gets the checklist inscript.py.makoapplied by hand. - Chain it:
down_revisionis the current head and nothing else. Runalembic headsbefore committing; one line. - Prove it locally on a scratch database (S9):
alembic upgrade headfrom empty,alembic downgrade -1,alembic upgrade head, then the model-shape test for the change (Tests). - Commit the model, the revision and the test together, with the revision id in the commit message.
- Ship:
scripts/migrate.shrunsalembic upgrade headunder a Postgres session advisory lock (id8110731, lines 57 to 64) before the new image is tagged live; the new container boots,verify_schema_at_head()passes, the log lineDatabase schema verified at Alembic head(line 282) appears once per process. - Roll back, if needed, as schema plus image, in that order of execution. The old image refuses to boot against the newer schema for the same reason the new one refused against the older one, and it cannot undo that schema either: the revision file that holds the
downgrade()exists only in the new image, and Alembic resolves the version-table row against theversions/directory it is run from. Measured 2026-09-11 on a scratch database atsubscription_cancel_otp_001with a copy of the fd8558f tree that lacks20260911_0002_subscription_cancel_otp.py:alembic current,alembic downgrade workspace_documents_001andalembic upgrade headall stop withFAILED: Can't locate revision identified by 'subscription_cancel_otp_001'before touching the database (Acceptance check 5). So the rollback isalembic downgrade <previous head>from a one-off container of the new image, the same shape as the migrate step in Deploy & ops;alembic currentfrom that container prints the previous head; then the tag swap back to the old image, whose boot check now finds the set it expects. The wiki names the downgrade and not the image (tooling/deployment.md, "Promoting to prod on the estate", verified 2026-09-05); S13 Deploy & ops names the retag.
Prima adds a step 0, in two halves. First, the chain owns its version table: migrations/env.py passes version_table=VERSION_TABLE to context.configure in both the offline and the online branch (lines 73 and 111), and the constant lives in prima/schema_guard.py (line 28, prima_alembic_version) so the guard and the runner cannot disagree; a service built from this recipe names its own table the same way (<service>_alembic_version) in one constant, because a chain left on Alembic's default alembic_version is what the guard treats as a Labs database (schema_guard.py line 67). Second, migrations/env.py inspects the live table list before configuring the context and raises NotPrimaDatabase if any of nine Labs marker tables or a bare alembic_version is present (line 86, prima/schema_guard.py; the nine are users, workspaces, workspace_users, workspace_settings, user_surface_grants, mcp_audit_logs, prima_wallets, prima_ledger, prima_user_profiles, lines 43 to 53, chosen because Prima will never create them). The marker set is chosen per service from tables it never creates and is not a constant to copy: a service that carries the S5 and S6 tables (users, workspaces, workspace_users, user_surface_grants) must not list them, or the guard refuses the service's own database after its first upgrade head (the 2026-09-12 rebuild copied the list and its alembic current raised the guard's error until the set was narrowed); then it calls connection.rollback() (106) before context.begin_transaction(), because under SQLAlchemy 2.0 the inspection opened an implicit transaction that Alembic would otherwise adopt and never commit (the comment at lines 88 to 105: "without it every migration silently does nothing", found when alembic current returned nothing after a green upgrade head). The connection.commit() at line 118 is belt and braces for the same reason.
Invariants
- Exactly one head and exactly one base at every commit. Labs:
tests/v2/test_m1_alembic_chain.py(7 tests, static, no database;test_exactly_one_head,test_exactly_one_base, plus self-proving guards that build a broken in-memory map and assert the same checks go red) and the CImigration-checkjob (test -z "$(alembic heads | sed -n 2p)", line 300). Prima:tests/test_migration_chain.py(8 tests, parsed textually so they run without configuration or imports). Both passed 2026-09-11. - The database at boot is at the code's head, or the process does not serve. Measured 2026-09-11 on the scratch database: after
alembic downgrade -1,verify_schema_at_head()raisesDatabase is not at Alembic head. Current revision(s): {'workspace_documents_001'}; code expects: {'subscription_cancel_otp_001'}; afterupgrade headit logs the verified line. - The chain builds from empty. Labs: 91 upgrades in 4 seconds on the local
postgres:14with the one variable set; Prima: 5 upgrades. A revision that assumes a table the chain did not create fails here first. - Every revision downgrades one step and upgrades again (the local round trip in Acceptance checks; CI once its job is repaired, Gotchas).
- No
sa.Enumin a revision after 2026-07-10; string plus CHECK for a value set;ADD VALUE IF NOT EXISTSfor a legacy enum, in a revision that does not use the value. - No
ON DELETE CASCADEin Prima.tests/test_migration_chain.py::test_no_migration_creates_a_cascadewalks the AST of a revision forondelete="CASCADE"and for the word inside asa.textorop.executeliteral, so a comment that discusses the defect passes and a statement that recreates it fails. - A revision that needs a value from its environment refuses to run rather than guess (the WebAuthn file). A revision that needs nothing from its environment imports nothing from the application.
prima_alembic_versionandalembic_versionnever coexist in one database by Prima's doing: the guard refuses the Labs table set before the first statement.- The metadata the tests build (
create_all()) and the schema the chain builds are documented as different sets (Gotchas), and a new revision's docstring says which one the change was tested against (three of the twelve September 2026 revisions do; Data model).
Frontend
No page renders a migration. The surfaces a person reads are:
CLI (operator, from the app image or a checkout)
alembic heads one line, the head id
alembic current the row in the version table, "(head)" when caught up
alembic history the chain by semantic id, newest first
boot log (every process)
"Database schema verified at Alembic head: {'<id>'}" core/database.py line 282
"Database schema verification failed: <RuntimeError>" line 329, then the process exits
CI (GitHub Actions, job "Database Migration Check")
one head, upgrade head, downgrade -1, upgrade head on postgres:15
The minimum admin surface this recipe asks for is the boot log line above; there is no page in Labs at fd8558f that shows the running revision. A monitoring-ops row that lists each app's alembic current next to its .deployed_commit is planned and does not exist; the nearest existing pattern is the status surface S12 names for scheduler jobs.
Security & tenancy
- The migration runs as the application's own database role with the application's own URL, resolved by the application's own settings object (
settings.SYNC_DATABASE_URLon Labs,load_settings().database_urlon Prima). Neither chain reads a URL from an ini file; neither prints one. Alembic's own logging prints revision ids and messages, not the DSN; the acceptance checks below still filter output for the password field out of habit. - Fail direction is closed on both chains: the app does not serve when behind (Labs), the migration does not run against the wrong database (Prima), the one environment-dependent backfill does not run without its value (Labs). Nothing in this recipe fails open.
- Prima's guard (
prima/schema_guard.py):LABS_MARKER_TABLESis nine table names chosen because Prima will never create them under any design (users,workspaces,workspace_users,workspace_settings,user_surface_grants,mcp_audit_logs, and the three tables of the older in-Labs Prima copilot,prima_wallets,prima_ledger,prima_user_profiles); a barealembic_versionwith noprima_alembic_versionbeside it is decisive on its own. The function is pure (a list of names in, an exception or nothing out) sotests/test_schema_guard.py(7 functions, 15 cases) covers it without a database. Measured 2026-09-11: pointed at the Labs scratch database,alembic upgrade headon Prima stopped withNotPrimaDatabase: Refusing to run Prima's migrations against this database. Labs tables are present: mcp_audit_logs, prima_ledger, prima_user_profiles, prima_wallets, user_surface_grants, users, workspace_settings, workspace_users, workspacesand executed no statement. - Tenancy is a schema property: the tenant column is
workspace_id(Labs) ortenant_id(Prima, the same UUID mirrored as a value, never a foreign key into Labs;prima/db.pylines 8 to 14), and the revision that adds it says how existing rows are treated: NOT NULL with a backfill or a server default when the value is known (magic_links.surfaceabove), nullable when it is not and every read path treats NULL as legacy and unverified (20260903_0002_projects_workspace_id.py, whose docstring says why a guessed tenant is worse than none; S6 owns the rule). Prima'sscript.py.makochecklist pins two more:tenant_idkeeps its originalworkspaces.idvalue because_row_hashhashes it first, andcreated_aton the meter stays a naive timestamp because it is inside the same hash;tests/test_migration_chain.py::test_created_at_is_not_timezone_aware_in_the_ledgerguards the second. ON DELETE CASCADEis a tenancy defect on an append-only table: the in-Labsprimaapi_usage_events.tenant_idcascades toworkspaces.id(20260802_0001_primaapi_v1.pylines 56 and 82), so deleting a workspace deletes its ledger. D3 (2026-09-11) names that copy legacy; Prima's rule is RESTRICT everywhere, measured as 0 cascading foreign keys at head, and the AST test keeps it so for the two files it lists (Gotchas for the three it does not).- Credentials at rest: none in a revision. The magic-link template seed (
20260803_0004_seed_prima_magic_link_template.py) writes copy, not keys; the ad-token encryption revision (20260610_0002_encrypt_ad_oauth_tokens.py) reads the key from settings at run time and stores ciphertext. - Concurrency:
scripts/migrate.shserialises concurrentupgrade headruns withpg_advisory_lock(8110731); a second run waits and finds nothing to do. Alembic itself has no lock. - Attacker-controlled input: none reaches a migration. A revision never reads a request, a file upload or a queue.
Tests
The S9 file names and what each pins, at fd8558f and cd2c248:
backend/tests/v2/test_m1_alembic_chain.py: one head, one base, everydown_revisionresolves, the self-proving red cases. Static; runs in 0.4 seconds;pytest tests/v2/test_m1_alembic_chain.py -p no:cacheprovider --no-covprinted7 passedon 2026-09-11.backend/tests/test_desk_domain_login_schema.py: the migration-shape half reads20260905_0001_desk_domain_login.pyas text and asserts the seven additive columns and the partial unique index are declared; the behaviour half usesdb_session. The pattern for any recipe whose invariant lives in a constraint: read the revision file, assert the constraint's name and predicate, then prove the constraint bites with anIntegrityErroron the transactional session.backend/tests/test_webauthn_multi_rp.py::test_migration_backfills_the_relying_party(line 598): loads the WebAuthn revision as a module, setsWEBAUTHN_RP_ID_BACKFILLto a real relying party inos.environ(line 569) and runs itsupgrade()(587), so it proves the backfill with the value set. The refusal path (_NOT_A_REAL_RP,_backfill_rp_id(), lines 67 to 108) has no unit test at fd8558f; Acceptance check 3 is the only place it is exercised.test_surface_rp_id.pytests the surface config alias and does not touch the migration.prima/tests/test_migration_chain.py(8 cases) andprima/tests/test_schema_guard.py(7 functions, 15 cases; one is parametrised over the nine marker tables, line 37): the chain shape and the guard,23 passed in 0.03stogether on 2026-09-11. Hermetic.prima/tests/test_credits_live.py,test_credit_grant_live.py,test_key_sync_live.py,tests/auth/test_repository_live.py: the CHECK constraints and the unique indexes against the migrated schema, opt-in throughPRIMA_TEST_DATABASE_URL; theirenginefixture skips whenprima_credit_accountsis absent, because "building the tables from the ORM would make these constraint tests check the ORM against itself" (test_credits_live.pylines 51 to 53). 79 tests; 77 passed on the scratch database on 2026-09-11 and 2 failed for a reason recorded in S9's Gotchas (a hard-coded date, not a schema defect).- The migration round trip itself:
alembic upgrade headfrom empty,alembic downgrade -1,alembic upgrade head, on a scratch database on the S9 Postgres. CI is written to do this onpostgres:15and does not reach it (Gotchas); a recipe author does it locally before the commit. Known-answer vectors do not apply to DDL; the "vector" for a migration is the table count and the head id after the run, and both are in Acceptance checks. - Playwright: not applicable. A migration has no user-facing state of its own.
Deploy & ops
Config keys per app heredoc (S13 owns the heredoc; these are the names it must carry): Labs DATABASE_URL (or the five DB_* parts; on the estate the parts are what the env file carries, and a script that expects the URL resolves it to empty, per the wiki), WEBAUTHN_RP_ID_BACKFILL set to the real relying-party host for the one revision that needs it (only matters on a database that has never run webauthn_rp_id_001; a database already past it ignores the variable), which no heredoc renders at alchemycloud b3d34da (S13 records the absence as a dated deviation on 2026-09-11; the estate's databases are past the revision, a fresh host's first migrate.sh is where it bites); Prima PRIMA_DATABASE_URL.
Migration order, per environment: build the image, run scripts/migrate.sh from that image against the environment's env file (a one-off container: docker run --rm --network host --env-file <env> -w /app/backend <image> sh /app/scripts/migrate.sh), then tag and restart. Never restart the old container between the migration and the swap: its own head check fails against the newer schema. Never put the migration in the image CMD. The wiring per platform is listed in the script's header (a release command, a pre-deploy command, a Kubernetes pre-rollout Job, a compose one-shot migrate service the app depends_on with service_completed_successfully).
Staging follows the same order. The older advice "never alembic upgrade head on staging, patch drift with idempotent DDL" (wiki tooling/deployment.md, the schema-drift section) is retired: since commit 99709a9 the boot check makes the opposite true, and the wiki records a 15-minute staging outage on 2026-08-25 from shipping a revision without applying it (/health answered nothing internally, 502 publicly, the container in a crash loop). The recovery is the one-off container above, then docker compose up -d --force-recreate.
Scheduler jobs: none. A migration runs once; anything that runs repeatedly is a job (S12).
Argus checks: no check reads the version table (argus, fix/collapse-host-blindness, e42e467, opened 2026-09-11; the word alembic appears only in a runbook regex). The boot refusal is the check: a process that is behind never answers /health, so the existing liveness probe on the app is what pages.
Rollback: schema first, then image, and the schema step runs from the new image. alembic downgrade <previous head> is issued from a one-off container of the image that shipped the revision, the same shape as the migrate step (docker run --rm --network host --env-file <env> -w /app/backend <new image> python -m alembic downgrade <previous head>), because only that image's migrations/versions/ carries the revision and its downgrade(). The old image cannot run downgrade, upgrade head or even current against a database that is ahead of its chain: each stops with FAILED: Can't locate revision identified by '<new head>' before any statement (measured 2026-09-11, Application logic step 7 and Acceptance check 5). Verify with alembic current from the same container, then retag to the old image and docker compose up -d; verify_schema_at_head() in the old image now finds the set it expects. A revision whose downgrade() is a no-op (an enum ADD VALUE) leaves the value in place, which the old code ignores. This supersedes the rollback sentence in S4's Deploy & ops ("re-point the image tag and re-run migrate.sh only when the migration is additive"): under the boot check an old image re-pointed at a newer schema crash-loops whether the revision was additive or not, because verify_schema_at_head() (core/database.py lines 273 to 280) compares sets, not directions, and migrate.sh re-run from that image fails on the same unknown revision instead of helping. The downgrade comes first, from the new image.
git archive for the shipped tree, never rsync of a working directory: the wiki records Alembic tripping over macOS ._* AppleDouble files copied into migrations/versions/ (SyntaxError: source code string cannot contain null bytes).
Gotchas
- 2026-09-11 (measured, fd8558f): the Labs chain is not one transaction.
20251120_1857_425a04794b53_update_onboarding_steps_add_form_.pylines 30 to 34 issue a rawCOMMITaround its enumADD VALUE, so everything after it in a singleupgrade headrun commits revision by revision. Observed: anupgrade headthat stops atwebauthn_rp_id_001leaves the database atprimaapi_v1_001with 177 tables, not empty. After any failure, runalembic currentbefore deciding anything; do not assume the run rolled back. - 2026-09-11 (measured, fd8558f): CI's
migration-checkjob is red before it reaches Alembic, and would be red after. ItsInstall dependenciesstep runspip install -r requirements.txtfrom the repo root (.github/workflows/ci-cd.ymllines 281 to 283) where no such file exists (requirements.txtis underbackend/; thebackend-testsjob in the same file setsworking-directory: ./backendfor exactly that reason, lines 66 to 72). Every run of the workflow thatgh run listreturns (six, 2026-06-01 to 2026-09-09) concludesfailure, and in each theDatabase Migration Checkjob fails atInstall dependencies. Behind that, theTest migrationsstep exports only the fiveDB_*variables (286 to 291) and noWEBAUTHN_RP_ID_BACKFILL, so on an empty database the chain would stop atwebauthn_rp_id_001with the refusal quoted under Stack assumptions. Two fixes, both in the job: theworking-directory, then theenv:line. Until both land, the local round trip in Acceptance checks is the gate that is known to be green. - 2026-09-11 (measured, fd8558f): the chain and the models disagree, in both directions, at the table level. The running app's metadata has 196 tables and the migrated database has 200:
aurum_signalsexists as a model and not in the chain (its drop revision ran; ADR-0029 called it a phantom on 2026-08-07 and it is still in the tree);brand_essence_drafts,conversation_history,knowledge_embeddings,platform_activity_bucketsandsystem_errorsexist in the chain and have no model.migrations/env.py's import list adds 20 modules the app never imports, which is why autogenerate is unusable as a source of truth (Application logic step 2). ADR-0029 measured the same disagreement at 946 statements including constraints and enum labels on 2026-08-07 and owes a corrective revision; nothing in this recipe changes either side. - 2026-09-11 (measured, fd8558f): the wiki's S8 row says "Postgres enum ADD VALUE in its own transaction" (
operations/prima-modules-inventory.md§1.1). The code does not do that: the elevenADD VALUE IF NOT EXISTSstatements in the chain run inside Alembic's transaction (the 2025-11-20 file is the one exception, and it commits by hand for the opposite reason), and20260907_0001_leadsource_add_booking.pyrecords the actual rule in its docstring: safe inside the transaction as long as the same transaction does not insert the new value. The code wins; write it the 2026-09-07 way. - 2026-09-11 (measured, fd8558f): the wiki's S8 row also says "never
upgrade headon staging". Retired by commit99709a9(the boot check) and contradicted by the wiki's own 2026-08-25 correction two sections later. The code wins: staging is migrated the same way as production, before the swap. - 2026-09-11 (measured, fd8558f):
backend/migrations/versions/holds 92 files, of which20260121_add_tracking_site_id_to_projects.sqlis not a revision and Alembic ignores it;alembic historyprints 91 rows.modules/AUTHORING.mdsays 92 revision files in the harness paragraph; the number to quote is 91 revisions. The 40-odd.sqlandrun_*.pyfiles one level up inbackend/migrations/are the pre-Alembic era and are run by nothing. - 2026-09-11 (measured, fd8558f):
migrations/env.pylines 19 to 27 say the import list must cover every module inbackend/models/; seven modules are absent (prima_key_sync,prima_signup_intent_model,primalayered_spend_models,service_desk_model,sparks_billing,user_login_email_model,user_surface_grant_model), six of them reached anyway throughmodels/__init__.py, one (primalayered_spend_models) not at all. Their tables exist in the chain, so nothing is broken; autogenerate would propose dropping the twoprimalayered_*tables, which is one more reason for step 2. - 2026-09-11 (measured, cd2c248): Prima's
test_no_migration_creates_a_cascadeis parametrised over0001_prima_meter.pyand0002_prima_credits.pyonly; the three later revisions (prima_auth_001,prima_key_sync_001,prima_sparks_buckets_001) are not in the list. They declare four RESTRICT foreign keys and no cascade (measured at the database: 0), so the invariant holds and the test does not yet say so. Add the file name to the list when adding a revision. - 2026-09-11 (measured, cd2c248):
migrations/env.py's docstring still says "theversions/directory is empty and that is correct" and describes a Labs two-head fork that was converged on 2026-08-02; the code below the docstring is current, the prose above it is from the first commit. Read the guard and the rollback, not the paragraph. - 2026-09-11 (measured, fd8558f): the
alembic.inifile_templatewould name a new file20260911_1432_<hash>_<slug>and give it a hash id; the rule in Data model is theYYYYMMDD_NNNN_<slug>name with a semantic id. Every non-merge revision since 2026-06-09 was written by hand in that shape; the one generator-shaped file after that date is the 2026-08-02 merge revision20260802_1724_422b303029b6_converge_the_three_parallel_heads_from_.py, which kept the template's name and hash id. Write the file, do not generate it, or rename and re-id what the generator prints. - 2026-08-25 (wiki,
tooling/deployment.md): the staging crash loop from a shipped-but-unapplied revision, recorded above. The lesson generalises: the boot check turns "forgot to migrate" from a silent drift into an outage, on purpose, and the deploy path has to carry the migration step where a person cannot skip it. - 2026-08-07 and 2026-08-08 (wiki, ADR-0029): a production database that was never under Alembic has no correct stamp point; reconciling it meant diffing against a reference and stamping head after the residual diff was empty, and the first reference chosen (an empty database taken to head) would have dropped nine live columns because the chain does not reproduce what
create_all()had added over the years. The live staging schema was the correct reference. None of this applies to a service that follows this recipe from its first table; it applies to any service that adopts Alembic late. - 2026-08-02 (wiki,
tooling/deployment.md): "stamp first, then upgrade" on a database that already looks like the baseline; runningupgradefirst dies on the firstCREATE TABLEthat already exists. Also the origin of the merge revision422b303029b6and the baseline file. - 2026-09-11 (measured, cd2c248): Prima's
Makefilemigrateandrevisiontargets source.envinto the shell before calling Alembic;alembic upgrade headtyped directly in the directory with noPRIMA_DATABASE_URLin the environment fails atload_settings()naming the variable. That is the design (Stack assumptions), not a defect; a recipe that tells the reader to "run alembic" on Prima tells them to runmake migrate.
Reference implementation
Paths are relative to each repo's root; commits as in measured_on and also_measured_on.
alchemy_labs(d1a-token-source, fd8558f; backend files unchanged fromfeat/service-deskb1089b6):backend/alembic.ini:script_location(5),file_template(8),timezone = UTC(14),truncate_slug_length = 40(17),version_locations(27), the placeholdersqlalchemy.url(36).backend/migrations/env.py: the comment on the import rule (19 to 27), the model import list (28 to 79),target_metadata = Base.metadata(89), the URL override from settings (92),compare_typeandcompare_server_default(113, 114, 138, 139),NullPool(131).backend/migrations/_phantom_guard.py:column_exists,index_exists,constraint_existsand the six*_if_missingand*_if_existshelpers, with the docstring that limits their use.backend/migrations/versions/:baseline_prod_schema_001.py(the base),deferred_fk_phantom_tables_001.py,folded_pending_migs_001.py,20260802_1724_422b303029b6_converge_the_three_parallel_heads_from_.py(three parents),20260803_0001_webauthn_rp_id.py(the refusing backfill, 67 to 108 and 119 to 139),20260802_0002_surface_grants.py(string plus CHECK, 53 to 79),20260907_0001_leadsource_add_booking.py(enumADD VALUE),20260905_0001_desk_domain_login.py(partial unique index throughop.create_index, 181 to 187; the backfillop.execute, 230),20260504d_workspace_rls_policies.py(sixop.executecalls),20260911_0002_subscription_cancel_otp.py(a complete small table, the shape to copy),20251120_1857_425a04794b53_update_onboarding_steps_add_form_.pylines 30 to 34 (the rawCOMMIT).backend/core/database.py: naming convention (21 to 30),_run_pending_migrations_RETIRED(69),_running_under_pytest(233 to 237),_alembic_script_directory(240 to 247),verify_schema_at_head(250 to 282),init_db(289 to 331).backend/core/config.py:DB_DSNaliased toDATABASE_URL(357),SYNC_DATABASE_URL(367 to 371).backend/models/__init__.py: the registration list and its three explanatory comments.scripts/migrate.sh: the header (1 to 32), the advisory lock and the singlealembic upgrade head(47 to 77)..github/workflows/ci-cd.yml: themigration-checkjob (253 to 303; the install step 281 to 283, theenv:block 286 to 291), and thebackend-testsjob'sworking-directorycomment (66 to 72) for contrast.backend/tests/v2/test_m1_alembic_chain.py,backend/tests/test_desk_domain_login_schema.py,backend/tests/test_webauthn_multi_rp.py(566 to 598).
prima(feature/per-model-pricing, cd2c248):alembic.ini(no URL, lines 5 to 9),migrations/env.py(imports 48 and 60, two metadatas 62,version_table=VERSION_TABLE73 and 111, guard 86, rollback 88 to 106, commit 118),migrations/script.py.mako(the checklist),prima/schema_guard.py(VERSION_TABLE28,LABS_VERSION_TABLE56,LABS_MARKER_TABLES,assert_not_labs_database, the bare-version-table test 67),prima/content_free.py(token_check,token_check_name, 221 to 227),prima/db.pylines 8 to 14 (mirrored values, not foreign keys),Makefile(migrate,revision),docker-compose.ymlline 16 (Postgres 16 on 5433).migrations/versions/0001_prima_meter.py,0002_prima_credits.py,20260803_0001_auth_seam.py(_token_checks, 64 to 76; the RESTRICT keys, 165 to 200),20260804_0001_key_sync_seq.py,20260808_0001_two_bucket_sparks.py.tests/test_migration_chain.py,tests/test_schema_guard.py,tests/test_credits_live.pylines 51 to 53.
argus(fix/collapse-host-blindness, e42e467): no reader of either version table.- Wiki
philosophers_stone(d4f5b95):tooling/deployment.md("Alembic IS the schema source on v3", "Promoting to prod on the estate", the 2026-08-25 correction box),operations/decisions/0029-prod-schema-reconcile-diff-and-stamp.md,operations/prima-modules-inventory.md§1.1 row S8 and §2.
Deviations of the references from this recipe, all dated 2026-09-11: the chain is not atomic (the 2025-11-20 COMMIT); CI's migration job fails at dependency install and lacks the backfill variable; nine of the twelve September 2026 revisions omit the test-schema sentence; the WebAuthn refusal path has no unit test; the models and the chain differ by one phantom model and five model-less tables; env.py's import list is incomplete and over-complete at once; Prima's cascade test covers two of five files; Prima's env.py docstring is stale; the in-Labs PrimaAPI copy cascades on tenant_id (legacy under D3); the ini file_template does not produce the file name the rule asks for; Prima has no boot-time head check.
Acceptance checks
Run without the author at the commits named above, against a scratch database on the S9 test Postgres (127.0.0.1:5434, role test_user, database created for the run and dropped after). LABS is the alchemy_labs checkout, PRIMA the prima checkout, PY the Labs backend interpreter. Expected observations are exact.
cd $LABS/backend && $PY -m alembic headsprints one line,subscription_cancel_otp_001 (head).ls migrations/versions/*.py | wc -lprints91;ls migrations/versions/*.sql | wc -lprints1.cd $LABS/backend && $PY -m pytest tests/v2/test_m1_alembic_chain.py -p no:cacheprovider --no-cov -qends7 passed.- Create
s8_chain_scratchon the test Postgres. WithDATABASE_URLpointing at it (SYNC_DATABASE_URLis a property derived from it,core/config.pylines 367 to 371, not a variable the config reads),ENVIRONMENT=developmentandWEBAUTHN_RP_ID_BACKFILLunset:$PY -m alembic upgrade head 2>&1 | grep -c "Running upgrade"prints68(67 revisions applied; the 68th line,Running upgrade primaapi_v1_001 -> webauthn_rp_id_001, is the revision that raises) and the run ends withRuntimeError: Refusing to backfill webauthn_credentials.rp_id with 'localhost';$PY -m alembic currentprintsprimaapi_v1_001 (branchpoint). Export the URL before anything else: with it unset the same command runs against the config default on the laptop's own Postgres (Stack assumptions). - Same database,
WEBAUTHN_RP_ID_BACKFILL=localhostexported:upgrade headprintsRunning upgrade24 more times (the refused revision's line appears in both runs, so 68 plus 24 covers the 91 distinct revisions; a single run from empty with the variable set prints 91) andalembic currentprintssubscription_cancel_otp_001 (head).SELECT count(*) FROM information_schema.tables WHERE table_schema='public'returns201;SELECT count(*) FROM pg_type WHERE typtype='e'returns54. From empty with the variable set, the whole run completes in under 10 seconds on a laptop (4 s measured). - Rollback order first, with the database at head: copy
$LABS/backend(without.venv) to a scratch directory and deletemigrations/versions/20260911_0002_subscription_cancel_otp.pyfrom the copy (the old image); from the copy,$PY -m alembic headsprintsworkspace_documents_001 (head), and$PY -m alembic current,$PY -m alembic downgrade workspace_documents_001and$PY -m alembic upgrade headeach endFAILED: Can't locate revision identified by 'subscription_cancel_otp_001'. Back in$LABS/backend(the new image),$PY -m alembic downgrade -1prints oneRunning downgrade subscription_cancel_otp_001 -> workspace_documents_001; from the copy,$PY -m alembic currentnow printsworkspace_documents_001 (head)and the logging one-liner below prints the verified line once. Then, in$LABS/backend,$PY -c "from core.database import verify_schema_at_head; verify_schema_at_head()"raisesRuntimeError: Database is not at Alembic head. Current revision(s): {'workspace_documents_001'}; code expects: {'subscription_cancel_otp_001'};$PY -m alembic upgrade headthen the same one-liner exits 0 with noRuntimeError(one config warning aboutEMAIL_TRANSPORTfalling back to Resend prints to stderr inENVIRONMENT=development, and no verified line, because-cconfigures no logging handler);$PY -c "import logging; logging.basicConfig(level=logging.INFO); from core.database import verify_schema_at_head; verify_schema_at_head()" 2>&1 | grep -c "verified at Alembic head"prints1. cd $LABS/backend && grep -lE "sa\.Enum\(|postgresql\.ENUM\(|CREATE TYPE" migrations/versions/2026{07,08,09}*.py | grep -v 20260710_0001prints nothing.grep -c "ADD VALUE IF NOT EXISTS" migrations/versions/20260907_0001_leadsource_add_booking.pyprints1.cd $PRIMA && .venv/bin/python -m pytest tests/test_migration_chain.py tests/test_schema_guard.py -p no:cacheproviderends23 passed.- Create
s8_prima_scratchon the same Postgres. With the twelvePRIMA_*variables fromtests/conftest.py'sVALID_ENVexported andPRIMA_DATABASE_URLpointing at it:.venv/bin/alembic upgrade headprints fiveRunning upgradelines endingprima_key_sync_001 -> prima_sparks_buckets_001;.venv/bin/alembic currentprintsprima_sparks_buckets_001 (head).SELECT count(*) FROM information_schema.referential_constraints WHERE delete_rule='CASCADE'returns0;... WHERE delete_rule='RESTRICT'returns5;SELECT count(*) FROM information_schema.table_constraints WHERE constraint_type='CHECK' AND constraint_name LIKE 'ck_%'returns50;SELECT to_regclass('alembic_version')returns NULL andSELECT to_regclass('prima_alembic_version')does not. - Guard:
PRIMA_DATABASE_URLpointed ats8_chain_scratchfrom check 4,.venv/bin/alembic upgrade headexits non-zero withprima.schema_guard.NotPrimaDatabase: Refusing to run Prima's migrations against this database.followed byLabs tables are present:and nine names;alembic_versionin that database still holdssubscription_cancel_otp_001and noprima_alembic_versiontable was created. cd $PRIMA && grep -c "connection.rollback()" migrations/env.pyprints1;grep -cE "^sqlalchemy.url" alembic.iniprints0.cd $LABS && sed -n 286,291p .github/workflows/ci-cd.yml | grep -c WEBAUTHNprints0andsed -n 275,283p .github/workflows/ci-cd.yml | grep -c working-directoryprints0(the two CI gaps in Gotchas; each prints1once the job is fixed and this recipe'sversionis bumped with the date).- Drop both scratch databases.
npm run checkin this repo prints0 problems.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.