T10.3 fix - the drop migration was deleting every project membership

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>
This commit is contained in:
2026-08-21 15:36:44 -05:00
parent ab7bce9f5b
commit 9f91e9e26b
3 changed files with 31 additions and 34 deletions

View File

@@ -555,7 +555,7 @@ deliberately deferred.
- **Suggested wave or follow-up:** next housekeeping pass, with the check
widened so it cannot recur.
### BL-026 — `notify.send_now` is orphaned once D13 removes password reset
### BL-026 — CLOSED 2026-08-21 (removed; nothing referenced it)
- **Found during:** `T10.3` (D13), stripping the password code paths
- **Where:** `server/notify.py`, `send_now()`
@@ -563,15 +563,14 @@ deliberately deferred.
only caller was `forgot_password`, because a reset link must not sit in a queue.
`T10.3` deleted that endpoint, so the function now has no callers anywhere in
`server/` or `tests/` — verified by grep, not assumed.
- **Why not now:** deleting it is a drive-by CLAUDE.md forbids, and it is not
obviously dead weight: an immediate, unqueued send is the right primitive for any
future time-sensitive mail, and the queue is the wrong shape for that. Deciding
between "delete it" and "keep it as the documented immediate-send path" is a
judgement call that deserves its own entry rather than being settled inside an
auth task.
- **Suggested wave or follow-up:** next housekeeping pass. If kept, its docstring
needs rewriting — it currently explains itself in terms of password resets, which
no longer exist.
- **Resolution:** deleted. Raised as a judgement call between "remove it" and "keep
it as the documented immediate-send path"; answered on Aug 21 — remove it. Nothing
in `server/` or `tests/` referenced it, and its docstring explained itself entirely
in terms of password resets, which no longer exist. Keeping an unused sender that
bypasses the outbox is a liability, not an asset: the next person to need immediate
mail should write it against the requirement they actually have.
- **Note:** `send_email` (the raw SMTP call it wrapped) is untouched and still used by
the outbox.
### BL-027 — Okta exists on this estate; OIDC is a live alternative to the LDAPS bind