Found by actually seeding the pre-migration schema and rolling forward, rather
than by checking that the column disappeared. The column disappeared correctly;
project_members came back empty.
batch_alter_table emulates ALTER on SQLite by rebuilding the table - create a
new one, copy the rows, DROP the original, rename. server/alembic/env.py:22
imports the engine from server/db.py, which registers a connect listener
setting PRAGMA foreign_keys=ON, so that DROP TABLE cascaded through
project_members.user_id (ondelete="CASCADE") and took every membership row with
it. No error, nothing in the log, and the users table looked perfect
afterwards.
Production would have escaped it - Postgres does a real ALTER TABLE DROP COLUMN
and touches nothing else - so this was a local-dev and test-fixture data loss,
which is worse in one specific way: the tests CLAUDE.md requires run against a
throwaway SQLite database, so the suite would have been validating behaviour
against silently emptied membership tables.
Wrapping the batch in PRAGMA foreign_keys=OFF is not the fix: that pragma is a
no-op inside a transaction and alembic runs migrations in one. The rebuild is
simply unnecessary - SQLite has had native ALTER TABLE DROP COLUMN since 3.35
(2021), this runtime has 3.42, and Postgres has always had it. Plain
op.drop_column touches one table and cascades nowhere.
The reasoning is written into the migration's docstring as a DO NOT, because
batch_alter_table is the reflexive thing to reach for when a migration has to
work on SQLite and the failure is invisible.
Re-verified with memberships in the fixture:
3/3 users survive, roles intact (admin still admin)
2/2 project_members survive
downgrade -1 -> column back, nullable; upgrade -> gone again
Also closed BL-026: notify.send_now removed. Nothing referenced it and its
docstring described itself entirely in terms of password resets. send_email,
which it wrapped, is untouched and still used by the outbox.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>