"""artist_membership_suggestion — proposing that a creator and a membership match. Milestone 388, step E4. ## What this migration deliberately does NOT add No association table between Artist and Source, and no schema change to either. E4's first job was to verify what was actually missing, and the answer was neither the model nor the flows: `Source.artist_id` is a plain FK so many sources per artist already works, `POST /api/sources` already takes an `artist_id`, the add-source dialog already attaches to an EXISTING artist, and `SourceService.reassign` already moves a source between artists with post and image re-attribution. Building a parallel association table for a relationship the schema already expresses would have been the mistake rule 28 names. What was missing is the SUGGESTION, and that is all this table holds. Accepting a suggestion adds a SOURCE under the existing artist — it never merges two artists. Adding a source is trivially undone; a wrong merge silently mixes two creators' work and corrupts tagging, series and provenance with nothing left to separate them by. Revision ID: 0096 Revises: 0095 Create Date: 2026-09-11 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "0096" down_revision: Union[str, None] = "0095" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( "artist_membership_suggestion", sa.Column("id", sa.Integer(), nullable=False), sa.Column("platform_membership_id", sa.Integer(), nullable=False), sa.Column("artist_id", sa.Integer(), nullable=False), sa.Column("score", sa.Float(), nullable=False), sa.Column("signals", sa.JSON(), nullable=True), # No CHECK on status (rule 36 considered and declined), matching # series_suggestion and post_association — the same review-queue # vocabulary and the same check-existing-enums lesson. sa.Column( "status", sa.String(length=16), server_default="pending", nullable=False, ), sa.Column( "created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False, ), sa.Column( "updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False, ), sa.PrimaryKeyConstraint("id", name=op.f("pk_artist_membership_suggestion")), # CASCADE both ways: a suggestion about a membership or an artist that # no longer exists is not a fact worth keeping, and a dangling proposal # would render as a broken row in the review queue. sa.ForeignKeyConstraint( ["platform_membership_id"], ["platform_membership.id"], ondelete="CASCADE", name=op.f("fk_artist_membership_suggestion_membership"), ), sa.ForeignKeyConstraint( ["artist_id"], ["artist.id"], ondelete="CASCADE", name=op.f("fk_artist_membership_suggestion_artist_id_artist"), ), sa.UniqueConstraint( "platform_membership_id", "artist_id", name="uq_artist_membership_suggestion_pair", ), ) op.create_index( op.f("ix_artist_membership_suggestion_platform_membership_id"), "artist_membership_suggestion", ["platform_membership_id"], ) op.create_index( op.f("ix_artist_membership_suggestion_artist_id"), "artist_membership_suggestion", ["artist_id"], ) op.create_index( op.f("ix_artist_membership_suggestion_status"), "artist_membership_suggestion", ["status"], ) def downgrade() -> None: op.drop_index( op.f("ix_artist_membership_suggestion_status"), table_name="artist_membership_suggestion", ) op.drop_index( op.f("ix_artist_membership_suggestion_artist_id"), table_name="artist_membership_suggestion", ) op.drop_index( op.f("ix_artist_membership_suggestion_platform_membership_id"), table_name="artist_membership_suggestion", ) op.drop_table("artist_membership_suggestion")