"""Synthetic posts — FC authors a post for content that arrived as chat. Milestone 388, step E2. Discord is a delivery channel, not a publisher: one message is not one post, and today every message becomes its own `post` row competing with authored work for the same surface. This adds the three columns that let FC group a creator's variant drop into a post it wrote itself, while keeping that fact visible and the grouping reversible. ## Why a flag and a back-pointer rather than a separate table A synthetic post has to BE a post — same row, same columns — or every existing surface (feed, provenance, translation, attachments, series) would need a second code path for it. `synthesized_by` marks the ones FC authored; `absorbed_by_post_id` points a member message-post at the post that replaced it in the feed. The members are not deleted: they remain the images' true origin, and destroying them would make the grouping un-auditable at exactly the moment somebody wants to check it. Reversal is one DELETE. `absorbed_by_post_id` is ON DELETE SET NULL, so removing a synthetic post releases its members and they return to the feed unaided. Revision ID: 0092 Revises: 0091 Create Date: 2026-09-10 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "0092" down_revision: Union[str, None] = "0091" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # No CHECK on synthesized_by (rule 36 considered and declined): there is one # grouper today and a second would be a new VALUE, not a new invariant — # matching source.error_type and service_seen.kind. op.add_column("post", sa.Column("synthesized_by", sa.String(length=32), nullable=True)) op.add_column("post", sa.Column("synthesis_details", sa.JSON(), nullable=True)) op.add_column( "post", sa.Column("absorbed_by_post_id", sa.Integer(), nullable=True), ) op.create_index( op.f("ix_post_absorbed_by_post_id"), "post", ["absorbed_by_post_id"], ) # SET NULL, not CASCADE: deleting the synthetic post must RELEASE its # members, never take them with it. The members are the real capture. op.create_foreign_key( "fk_post_absorbed_by_post_id_post", "post", "post", ["absorbed_by_post_id"], ["id"], ondelete="SET NULL", ) # Grouping tunables. Every one of these is operator-facing (project rule # 25) because the quality bar here is a judgement call no test can settle: # too greedy merges distinct pieces, too shy leaves a drop scattered. op.add_column( "ml_settings", sa.Column( "discord_grouping_enabled", sa.Boolean(), server_default="true", nullable=False, ), ) op.add_column( "ml_settings", sa.Column( "discord_group_max_distance", sa.Float(), server_default=sa.text("0.10"), nullable=False, ), ) op.add_column( "ml_settings", sa.Column( "discord_group_window_minutes", sa.Float(), server_default=sa.text("60"), nullable=False, ), ) def downgrade() -> None: op.drop_column("ml_settings", "discord_group_window_minutes") op.drop_column("ml_settings", "discord_group_max_distance") op.drop_column("ml_settings", "discord_grouping_enabled") op.drop_constraint("fk_post_absorbed_by_post_id_post", "post", type_="foreignkey") op.drop_index(op.f("ix_post_absorbed_by_post_id"), table_name="post") op.drop_column("post", "absorbed_by_post_id") op.drop_column("post", "synthesis_details") op.drop_column("post", "synthesized_by")