"""post_association — "this Patreon post announced that Discord drop". Milestone 388, step E5. Two of the operator's artists post a deliberately cropped fragment on Patreon to signal that the real thing has landed in their Discord. This table holds the proposed and accepted links between the announcement and the drop. Directional and confirm-only. The pair is asymmetric (the teaser announces the drop, not the reverse), the two posts are never merged (the creator published twice, deliberately — flattening that hides the behaviour being modelled), and nothing is linked until the operator accepts, following the FC-6.3 series matcher. A wrongly-asserted association tells them two different pieces are one, which is worse than no link at all. Dismissed rows are KEPT. The row is what remembers the rejection, and re-proposing a rejected pair on every scan is what makes a review queue get ignored. Revision ID: 0094 Revises: 0093 Create Date: 2026-09-10 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "0094" down_revision: Union[str, None] = "0093" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( "post_association", sa.Column("id", sa.Integer(), nullable=False), sa.Column("announcement_post_id", sa.Integer(), nullable=False), sa.Column("payload_post_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.status — 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_post_association")), # CASCADE on both sides: an association to a post that no longer exists # is not a fact worth keeping, and E3's reversal path (delete the # grouping) must not leave a dangling proposal behind. sa.ForeignKeyConstraint( ["announcement_post_id"], ["post.id"], ondelete="CASCADE", name=op.f("fk_post_association_announcement_post_id_post"), ), sa.ForeignKeyConstraint( ["payload_post_id"], ["post.id"], ondelete="CASCADE", name=op.f("fk_post_association_payload_post_id_post"), ), sa.UniqueConstraint( "announcement_post_id", "payload_post_id", name="uq_post_association_pair", ), ) op.create_index( op.f("ix_post_association_announcement_post_id"), "post_association", ["announcement_post_id"], ) op.create_index( op.f("ix_post_association_payload_post_id"), "post_association", ["payload_post_id"], ) op.create_index( op.f("ix_post_association_status"), "post_association", ["status"], ) op.add_column( "import_settings", sa.Column( "discord_link_enabled", sa.Boolean(), server_default="true", nullable=False, ), ) # 0.60 sits ABOVE the largest single signal weight on purpose — see # post_association_service.WEIGHTS. That is what makes "time proximity # alone is never sufficient" arithmetic rather than aspirational. op.add_column( "import_settings", sa.Column( "discord_link_threshold", sa.Float(), server_default="0.60", nullable=False, ), ) op.add_column( "import_settings", sa.Column( "discord_link_window_hours", sa.Float(), server_default="24", nullable=False, ), ) def downgrade() -> None: op.drop_column("import_settings", "discord_link_window_hours") op.drop_column("import_settings", "discord_link_threshold") op.drop_column("import_settings", "discord_link_enabled") op.drop_index(op.f("ix_post_association_status"), table_name="post_association") op.drop_index( op.f("ix_post_association_payload_post_id"), table_name="post_association", ) op.drop_index( op.f("ix_post_association_announcement_post_id"), table_name="post_association", ) op.drop_table("post_association")