From fadc05966e02263117580615e526411381b53bb7 Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 12 May 2026 03:39:17 +0200 Subject: [PATCH] feat(alembic): A5 migration 013 drops stray role postgres enum Smoke run 2 (2026-05-11) produced 'UndefinedFunctionError: operator does not exist: agentrole = role' because postgres had two enums (role, agentrole) for the same Python class. Information_schema check confirms no column uses role; migration drops it. Upgrade() raises if that ever stops being true. Downgrade() recreates the enum with the foundation's Role values. Investigation of WHY a second enum got created is tracked in spec E2; this migration handles the symptom. Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md section A5. --- alembic/versions/013_drop_role_enum.py | 67 +++++++++++++++++++ .../test_migration_013_drop_role.py | 49 ++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 alembic/versions/013_drop_role_enum.py create mode 100644 tests/integration/test_migration_013_drop_role.py diff --git a/alembic/versions/013_drop_role_enum.py b/alembic/versions/013_drop_role_enum.py new file mode 100644 index 00000000..fb3c696d --- /dev/null +++ b/alembic/versions/013_drop_role_enum.py @@ -0,0 +1,67 @@ +"""Drop stray `role` postgres enum. + +Smoke run 2 (2026-05-11) surfaced `UndefinedFunctionError: operator +does not exist: agentrole = role` because postgres ended up with two +enums (`role` and `agentrole`) for the same Python `Role` class. Only +agents.role column uses `agentrole`; nothing uses `role`. This migration +drops the orphan. + +The cause (E2 in the spec) is a SQLAlchemy parameter-binding edge case +where a query parameter wasn't bound with the column's enum name, +letting SQLAlchemy infer a new type from the Python class name. +Investigation tracked in E2; this migration handles the symptom. + +Revision ID: 013_drop_role_enum +Revises: 012_align_agentrole_foundation +Create Date: 2026-05-12 +""" + +from __future__ import annotations + +from alembic import context, op +from sqlalchemy import text + +revision = "013_drop_role_enum" +down_revision = "012_align_agentrole_foundation" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Drop the orphan `role` enum after confirming no column uses it.""" + if context.is_offline_mode(): + # Offline mode: emit the SQL but skip the safety check. + op.execute("DROP TYPE IF EXISTS role") + return + + conn = op.get_bind() + # Safety check: no column may still reference the type + result = conn.execute( + text( + "SELECT column_name, table_name " + "FROM information_schema.columns " + "WHERE udt_name = 'role'" + ) + ) + rows = list(result) + if rows: + raise RuntimeError( + f"refusing to DROP TYPE role: columns still reference it: {rows}" + ) + + conn.execute(text("DROP TYPE IF EXISTS role")) + + +def downgrade() -> None: + """Recreate the enum with the foundation's Role values. + + Used if we ever need to revert this migration. No rows reference + the type after the upgrade ran, so recreation is a no-op for app + state. + """ + op.execute( + "CREATE TYPE role AS ENUM (" + "'system', 'developer', 'qa', 'documenter', 'cell_pm', 'main_pm', " + "'product_owner', 'head_marketing', 'auditor', 'ceo'" + ")" + ) diff --git a/tests/integration/test_migration_013_drop_role.py b/tests/integration/test_migration_013_drop_role.py new file mode 100644 index 00000000..f25b3c07 --- /dev/null +++ b/tests/integration/test_migration_013_drop_role.py @@ -0,0 +1,49 @@ +"""Wave A5 (2026-05-12): migration 013 drops the stray `role` postgres enum. + +Smoke run 2 produced `UndefinedFunctionError: operator does not exist: +agentrole = role` because postgres had two enums for the same Python +class. The migration drops the unused one with a safety check. +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import text + + +@pytest.mark.asyncio +async def test_no_column_uses_role_type(db_session) -> None: # type: ignore[no-untyped-def] + """Before dropping, confirm no column actually uses the `role` type. + + If this ever fails it means a column was added that references the + `role` enum and the migration must NOT be allowed to drop it — that + column would become orphaned. + """ + result = await db_session.execute( + text( + "SELECT column_name, table_name " + "FROM information_schema.columns " + "WHERE udt_name = 'role'" + ) + ) + rows = list(result) + assert rows == [], ( + f"columns still use `role` enum: {rows}; migration 013 cannot run" + ) + + +@pytest.mark.asyncio +async def test_role_enum_dropped_after_upgrade(db_session) -> None: # type: ignore[no-untyped-def] + """After migration 013 runs, only `agentrole` remains; `role` is gone.""" + # This test runs against a db where migrations have been applied to head. + # The conftest fixture should handle that — verify by reading the + # existing tests' conftest. + result = await db_session.execute( + text( + "SELECT typname FROM pg_type " + "WHERE typname IN ('role', 'agentrole')" + ) + ) + rows = {row[0] for row in result} + assert "agentrole" in rows, "agentrole must remain (it's the live enum)" + assert "role" not in rows, "stray `role` enum should be dropped"