"""PostAssociation — "this Patreon post announced that Discord drop". Milestone 388, step E5, and the point of the milestone rather than its tail. Two of the operator's artists post a deliberately CROPPED fragment on Patreon to signal that the real thing has landed in their Discord. The Patreon post is the announcement; the Discord grouping (milestone 388 E2) is the payload. This row is the link between them. ## Directional, and NOT a merge `announcement` → `payload` is asymmetric on purpose. The teaser announces the drop; the drop does not announce the teaser, and a symmetric "related posts" edge would lose the only thing that makes the pair interesting. Nor are the two collapsed into one post. The creator published twice, deliberately, on two platforms with different audiences — flattening that hides the very behaviour being modelled, and would destroy the operator's ability to see that the Patreon post is a teaser at all. ## Confirm-only, following FC-6.3 (task 737) `status` starts at `pending` and nothing is linked until the operator accepts. A wrongly-asserted association tells them two different pieces are one, which is worse than no link: no link leaves them where they already are, a wrong one actively misinforms. Same reason the series matcher writes to a review queue instead of filing posts on its own. `status` is a plain String, no CHECK — matching `series_suggestion.status`, which records the same check-existing-enums lesson. ## Why pHash could not do this, and the correction matters The original plan claimed this link was already sitting in `image_provenance` via pHash dedup. It is not. `compute_phash` is `imagehash.phash` at `hash_size=8` — a DCT hash over the WHOLE image, robust to rescaling and recompression but NOT to cropping, because a crop changes the global signature. Cross-platform provenance still links straight re-posts; it does nothing for a cropped teaser and its full version, which is precisely the pair the operator described. Hence a scored proposal rather than a lookup. """ from datetime import datetime from sqlalchemy import ( JSON, DateTime, Float, ForeignKey, Integer, String, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column from .base import Base class PostAssociation(Base): __tablename__ = "post_association" __table_args__ = ( UniqueConstraint( "announcement_post_id", "payload_post_id", name="uq_post_association_pair", ), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) # The teaser — a real post the creator wrote (Patreon, today). announcement_post_id: Mapped[int] = mapped_column( ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True ) # What it announced — a synthetic Discord grouping, today. 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) should not leave a # dangling proposal behind. payload_post_id: Mapped[int] = mapped_column( ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True ) score: Mapped[float] = mapped_column(Float, nullable=False) # Per-signal strengths as scored, so a proposal stays explicable after the # weights or the threshold are tuned. Without it, "why was this suggested" # is unanswerable the moment anything moves. signals: Mapped[dict | None] = mapped_column(JSON, nullable=True) # pending | linked | dismissed. A DISMISSED row is kept, not deleted — it # is what stops the matcher proposing the same rejected pair on every # subsequent scan, which is the behaviour that makes a review queue # unusable. status: Mapped[str] = mapped_column( String(16), nullable=False, server_default="pending", index=True ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), )