feat: offer the creator you already track as the one you subscribe to (388 E4)
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-ml (push) Successful in 2m23s
Build images / build-web (push) Successful in 1m25s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m31s
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-ml (push) Successful in 2m23s
Build images / build-web (push) Successful in 1m25s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m31s
**The verification the step asked for came back "not the schema".**
`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
has an artist autocomplete that attaches to an EXISTING artist; and
`SourceService.reassign` already moves a source between artists WITH post and
image re-attribution. A sweep for one-source-per-artist assumptions found only
`func.count()` calls — the opposite of assuming one.
So no parallel association table was built for a relationship the schema
already expresses (rule 28). What was missing is FC OFFERING the link, and that
is all this adds.
**Accepting adds a SOURCE. It never merges two artists.** That asymmetry sets
the whole posture: adding a source is trivially undone, while a wrong merge
silently mixes two creators' work and corrupts tagging, series and provenance
downstream with nothing left to tell them apart by. A test asserts the artist
count is unchanged by accepting.
The weights encode the judgement rather than a code path doing it — name 0.65,
declared 0.35, cut at 0.60 — so that:
* an EXACT name match alone proposes (same slug on both sides is strong, and
demanding corroboration would propose almost nothing);
* a CONTAINMENT match alone does not ("art" sits inside "artgirl"), and short
slugs are excluded from containment entirely because a 3-character slug is
inside a great many longer ones;
* the declaration ALONE never proposes, because a creator may link another
creator's Patreon and a link is not a claim of identity.
A guard test pins all three against WEIGHTS directly and says not to fix a
failure by moving the numbers.
Two corrections carried forward from earlier steps rather than rediscovered:
* The declaration is NOT read from `ExternalLink`. `SUPPORTED_HOSTS` is file
hosts only and `host_for()` returns None for patreon.com, so no row is ever
written for one — the same trap that caught E5 for Discord invites. It reads
the raw body, because these links live in an `href` and `html_to_plain`
discards attributes.
* `vanity` is not a column: C1 modelled the roster before any platform was
characterised, which is exactly what `details` exists for. `vanity_or_none()`
reads it from there and falls back to the URL's last segment, so a row
written before the field was understood still resolves.
Two fixes during the writing. `accept()` first created a bare `Source()`,
skipping the platform/URL validation, duplicate check and #693 backfill-arming
that a hand-added source gets — a second, quieter way to create a source is how
two paths drift until one is subtly broken; it now goes through
`SourceService.create`. And the candidate query used a bare `exists().where()`,
which has no FROM to correlate against; now `select(...).exists()`.
Chained onto the roster sweep rather than given its own beat entry: a
suggestion can only be as good as the roster behind it, so any other cadence
would just propose from staler data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user