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
153 lines
7.2 KiB
Python
153 lines
7.2 KiB
Python
"""platform_membership — the learned roster of what the account actually pays for.
|
|
|
|
Milestone 387, phase C. FabledCurator knows which creators it has been TOLD to
|
|
follow (`source`), and nothing about which ones the operator is actually
|
|
subscribed to. Those two sets drift in both directions and the app cannot
|
|
currently see either drift:
|
|
|
|
* A subscription the operator pays for that FC does not track is content they
|
|
believe they are archiving and are not.
|
|
* A source FC keeps walking after the subscription lapsed is requests spent on
|
|
a wall, reported as a creator who has gone quiet.
|
|
|
|
This table is the memory that makes both visible — every membership the account
|
|
has been observed to hold, and when it was last seen.
|
|
|
|
## Why a learned roster rather than a live lookup
|
|
|
|
Same reasoning as `service_seen` (milestone 365), and the same shape: an
|
|
absence is only observable against a record of presence. A membership that
|
|
stops appearing in a sweep is the signal — "you were subscribed to this, now
|
|
you aren't" — and there is nowhere to read that from a live call, because a
|
|
live call returns what IS, never what stopped being.
|
|
|
|
It also means the reconciliation surface keeps working when Patreon is
|
|
unreachable, degraded to a stale roster with a visible age rather than an empty
|
|
page (rule 164).
|
|
|
|
## Roster truth, NOT per-post truth
|
|
|
|
The single most important thing about this table: `tier_names` says which tiers
|
|
the account holds. It does **not** say which posts those tiers unlock. A
|
|
creator can gate a post behind an access rule that maps onto no tier name at
|
|
all.
|
|
|
|
`current_user_can_view` — read per post by `patreon_client.post_is_gated` — is
|
|
the authoritative signal, and phase A already turned it into a durable
|
|
per-source state. This roster EXPLAINS that state ("you are no longer a patron"
|
|
vs "your tier doesn't cover these posts"). It must never be used to decide
|
|
whether to fetch something. Getting that backwards would make FC silently stop
|
|
fetching content the operator is paying for, which is the worst failure
|
|
available in this milestone.
|
|
|
|
## status is a plain String, and deliberately the platform's own word
|
|
|
|
Not a Postgres ENUM, not CHECK-gated — matching `service_seen.kind`,
|
|
`gpu_job.status` and `source.error_type`. Two reasons, and the first is the
|
|
real one:
|
|
|
|
1. **The vocabulary is not ours to invent.** Patreon says `active_patron` /
|
|
`former_patron` / `declined_patron`; SubscribeStar and FANBOX will say
|
|
something else. Storing each platform's own word verbatim and mapping to
|
|
FC's meaning at the READ site keeps this table a record of what was
|
|
observed rather than a lossy translation of it. A lowest-common-denominator
|
|
enum picked before any platform has been characterised (step C0) would be a
|
|
guess baked into the schema.
|
|
2. A constraint swap per new value (rule 36) would be cost with no invariant
|
|
behind it, exactly as `service_seen.kind` records.
|
|
|
|
The service layer owns the whitelist and the mapping; the column owns the
|
|
evidence.
|
|
|
|
## Retention: aged out, never deleted on disappearance
|
|
|
|
A membership that stops appearing in a sweep is NOT removed. Its disappearance
|
|
is the fact the reconciliation surface reads, and deleting the row would
|
|
destroy the signal at the moment it became interesting. `last_seen_at` is what
|
|
makes "gone" decidable, and a retention policy ages rows out on time rather
|
|
than on absence.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import JSON, DateTime, Integer, String, Text, UniqueConstraint, func
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from .base import Base
|
|
|
|
|
|
class PlatformMembership(Base):
|
|
__tablename__ = "platform_membership"
|
|
__table_args__ = (
|
|
# The natural key the sweep's upsert conflicts on. Named explicitly
|
|
# because `touch_membership` references it by name in ON CONFLICT.
|
|
UniqueConstraint(
|
|
"platform", "external_campaign_id",
|
|
name="uq_platform_membership_platform_campaign",
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
|
|
platform: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
# The platform's own id for the thing subscribed to — a Patreon campaign
|
|
# id, whatever SubscribeStar and FANBOX call theirs. Text rather than a
|
|
# bounded String: these are opaque upstream identifiers and guessing a
|
|
# ceiling for a value we do not mint is how a walk dies on a truncation.
|
|
external_campaign_id: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
|
# For the reconciliation UI, and for matching against Source.url — the
|
|
# vanity/URL is what the two sides actually have in common.
|
|
display_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# The platform's own word. See the module docstring — this is evidence,
|
|
# not a normalised FC status.
|
|
status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
|
|
# Nullable throughout: a free follow has no tier and no money attached, and
|
|
# a platform may not expose an amount at all. Absent must stay
|
|
# distinguishable from zero — "free" and "we don't know" are different
|
|
# answers to "what is this costing".
|
|
tier_names: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
|
amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
currency: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
|
|
|
# NEVER updated after insert. The one field that answers "has this ever
|
|
# been true", which is what makes a disappearance readable rather than
|
|
# indistinguishable from never having existed. `touch_membership`
|
|
# deliberately excludes it from the ON CONFLICT update set.
|
|
first_seen_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
|
)
|
|
last_seen_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
|
)
|
|
|
|
# The raw membership as the platform returned it, so a later question can
|
|
# be answered without re-fetching — and so a field we did not think to
|
|
# model is not lost. Displayed and never queried, like service_seen.details.
|
|
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
|
|
|
def vanity_or_none(self) -> str | None:
|
|
"""The platform's URL slug for this creator, if it can be known.
|
|
|
|
NOT a column, and that is C1's design working as intended rather than
|
|
an omission: the roster was modelled before any platform had been
|
|
characterised, so `details` exists precisely to carry the fields we did
|
|
not know to model. The vanity turned out to be one of them (#3886), and
|
|
it is reachable without a migration.
|
|
|
|
Falls back to the URL's last segment, which is what a vanity IS on
|
|
every platform seen so far — but only as a fallback, because the
|
|
platform's own word for it is the better answer when present.
|
|
"""
|
|
campaign = (self.details or {}).get("campaign") or {}
|
|
vanity = campaign.get("vanity")
|
|
if isinstance(vanity, str) and vanity:
|
|
return vanity
|
|
if self.url:
|
|
tail = self.url.rstrip("/").rsplit("/", 1)[-1]
|
|
return tail or None
|
|
return None
|