diff --git a/alembic/versions/0091_platform_membership.py b/alembic/versions/0091_platform_membership.py new file mode 100644 index 0000000..899f6e4 --- /dev/null +++ b/alembic/versions/0091_platform_membership.py @@ -0,0 +1,81 @@ +"""platform_membership — the learned roster of what the account actually pays for. + +Milestone 387, phase C. FC knows which creators it was told to follow and +nothing about which ones the operator is subscribed to; this table is the +memory that makes the drift in both directions observable. See the model +docstring for why the roster is learned rather than looked up live, and why +`status` holds the platform's own word rather than a normalised FC value. + +## Nothing populates this yet, on purpose + +The sweep that fills it (C3) depends on a client seam (C2) that depends on +characterising Patreon's real membership response from a captured sample (C0), +which needs the operator's authenticated browser session. The table's SHAPE +does not wait on that: it is deliberately free-form where C0's findings would +otherwise dictate a column — `status` is an unconstrained String and `details` +keeps the raw payload — so no capture can invalidate what is created here. + +An empty table is the correct intermediate state. It is not dead code: C5 reads +it to explain a tier-limited source, and C4 reads it to reconcile. + +Revision ID: 0091 +Revises: 0090 +Create Date: 2026-09-10 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0091" +down_revision: Union[str, None] = "0090" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "platform_membership", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("platform", sa.String(length=64), nullable=False), + # Text, not a bounded String: an opaque upstream identifier we do not + # mint, and guessing a ceiling for one is how a walk dies on a silent + # truncation. + sa.Column("external_campaign_id", sa.Text(), nullable=False), + sa.Column("display_name", sa.Text(), nullable=True), + sa.Column("url", sa.Text(), nullable=True), + # No CHECK, deliberately (rule 36 considered and declined): the + # vocabulary is each platform's own and is not ours to fix before C0 + # has characterised even one of them. The service owns the whitelist. + sa.Column("status", sa.String(length=32), nullable=True), + sa.Column("tier_names", sa.JSON(), nullable=True), + sa.Column("amount_cents", sa.Integer(), nullable=True), + sa.Column("currency", sa.String(length=8), nullable=True), + sa.Column( + "first_seen_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False, + ), + sa.Column( + "last_seen_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False, + ), + sa.Column("details", sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_platform_membership")), + # The upsert's conflict target. Named explicitly because + # touch_membership references it by name in ON CONFLICT — an + # autogenerated name would make that call break on a rename nobody + # connected to it. + sa.UniqueConstraint( + "platform", "external_campaign_id", + name="uq_platform_membership_platform_campaign", + ), + ) + # No secondary indexes. This table holds one row per subscription — tens, + # not millions — so every query against it is a short scan and an index + # would be write cost buying nothing (#3301 removed seven of that shape). + # The unique constraint above already backs the only lookup that matters. + + +def downgrade() -> None: + op.drop_table("platform_membership") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index fe4bf54..fc00a84 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -26,6 +26,7 @@ from .patreon_failed_media import PatreonFailedMedia from .patreon_seen_media import PatreonSeenMedia from .pixiv_failed_media import PixivFailedMedia from .pixiv_seen_media import PixivSeenMedia +from .platform_membership import PlatformMembership from .post import Post from .post_attachment import PostAttachment, attachment_download_url from .presentation_review import PresentationReview @@ -64,6 +65,7 @@ __all__ = [ "SeriesChapter", "SeriesPage", "SeriesSuggestion", + "PlatformMembership", "ServiceSeen", "ImageRecord", "ImageProvenance", diff --git a/backend/app/models/platform_membership.py b/backend/app/models/platform_membership.py new file mode 100644 index 0000000..509f2ba --- /dev/null +++ b/backend/app/models/platform_membership.py @@ -0,0 +1,130 @@ +"""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) diff --git a/backend/app/services/membership_roster.py b/backend/app/services/membership_roster.py new file mode 100644 index 0000000..b05bd91 --- /dev/null +++ b/backend/app/services/membership_roster.py @@ -0,0 +1,116 @@ +"""The learned membership roster: what the account actually subscribes to. + +Milestone 387, phase C. Sibling of `service_roster` (milestone 365) and built +on the same insight — an absence is only observable against a record of +presence. There, a stopped worker; here, a subscription that lapsed. + +## Nothing calls this yet + +`touch_membership` is written before its caller because the caller (the sweep, +C3) needs a client seam (C2) that needs Patreon's real response characterised +from a captured sample (C0), and that capture needs the operator's browser +session. The write side does not depend on any of it: an upsert keyed on +(platform, external_campaign_id) is the same regardless of what the payload +turns out to look like, and `details` carries whatever C0 finds. + +## Why the whitelist lives here and not in the column + +`platform_membership.status` is an unconstrained String holding the PLATFORM's +own word — `active_patron`, not some normalised FC value. The mapping from +those words to FC's meaning is a read-site concern and belongs in code that can +be corrected without a migration, because the vocabulary comes from whatever +each platform says and will be discovered per platform rather than designed up +front. `MEMBERSHIP_STATUS` below is a place for that knowledge to accumulate as +platforms are characterised; it is deliberately empty of guesses today. +""" + +from __future__ import annotations + +import logging + +from sqlalchemy import func +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import PlatformMembership + +log = logging.getLogger(__name__) + +# Platform word -> whether the account currently has paid access. +# +# EMPTY ON PURPOSE. Every entry here must come from a characterised response +# (step C0), not from what the API docs or a plausible guess suggest — that is +# the whole point of project rule 130, and inventing `active_patron` before +# seeing it in a real payload is exactly the failure it names. Populate per +# platform as each is characterised. +# +# Unknown words are NOT an error: an unrecognised status means the roster +# records evidence it cannot yet interpret, which is a better state than +# dropping the row or asserting a meaning for it. +MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = {} + + +def has_paid_access(platform: str, status: str | None) -> bool | None: + """Does this status mean the account currently pays for access? + + Returns None for a status this code has not been taught, which callers must + treat as "unknown" rather than as False. The difference matters: False says + the operator has lost access, and asserting that from an unrecognised word + would tell them to cancel a source they are still paying for. + """ + if status is None: + return None + entry = MEMBERSHIP_STATUS.get(platform, {}) + return entry.get(status) + + +async def touch_membership( + session: AsyncSession, + *, + platform: str, + external_campaign_id: str, + display_name: str | None = None, + url: str | None = None, + status: str | None = None, + tier_names: list | None = None, + amount_cents: int | None = None, + currency: str | None = None, + details: dict | None = None, +) -> None: + """Record that this membership was observed just now. + + Upsert rather than read-modify-write, for the same reason as + `service_roster.touch_service`: a sweep may overlap its own previous run, + and the last writer is simply the most recent sighting. + + `first_seen_at` is deliberately NOT in the update set. It is the one field + that answers "has this ever been true", which is what makes a membership's + later DISAPPEARANCE readable as a lapse rather than indistinguishable from + a creator FC never knew about. Every other column is last-writer-wins, + including status — a membership that goes from active to former must move. + """ + stmt = pg_insert(PlatformMembership).values( + platform=platform, + external_campaign_id=external_campaign_id, + display_name=display_name, + url=url, + status=status, + tier_names=tier_names, + amount_cents=amount_cents, + currency=currency, + details=details or {}, + ) + stmt = stmt.on_conflict_do_update( + constraint="uq_platform_membership_platform_campaign", + set_={ + "display_name": stmt.excluded.display_name, + "url": stmt.excluded.url, + "status": stmt.excluded.status, + "tier_names": stmt.excluded.tier_names, + "amount_cents": stmt.excluded.amount_cents, + "currency": stmt.excluded.currency, + "details": stmt.excluded.details, + "last_seen_at": func.now(), + }, + ) + await session.execute(stmt) diff --git a/tests/test_membership_roster.py b/tests/test_membership_roster.py new file mode 100644 index 0000000..3172866 --- /dev/null +++ b/tests/test_membership_roster.py @@ -0,0 +1,175 @@ +"""Milestone 387 C1: the learned membership roster. + +The load-bearing property is that `first_seen_at` survives every re-observation +— it is what makes a membership's later DISAPPEARANCE readable as a lapse +rather than indistinguishable from a creator FC never knew about. Everything +else is last-writer-wins, including status, because a membership that goes from +active to former has to move. +""" +import pytest +from sqlalchemy import select + +from backend.app.models import PlatformMembership +from backend.app.services.membership_roster import ( + MEMBERSHIP_STATUS, + has_paid_access, + touch_membership, +) + +pytestmark = pytest.mark.integration + + +async def _row(db, platform="patreon", campaign="c1"): + return (await db.execute( + select(PlatformMembership).where( + PlatformMembership.platform == platform, + PlatformMembership.external_campaign_id == campaign, + ) + )).scalar_one() + + +@pytest.mark.asyncio +async def test_touch_inserts_a_new_membership(db): + await touch_membership( + db, platform="patreon", external_campaign_id="c-new", + display_name="Maewix Studios", url="https://patreon.com/maewix", + status="some_platform_word", tier_names=["Sketches"], + amount_cents=500, currency="USD", details={"raw": 1}, + ) + await db.commit() + + row = await _row(db, campaign="c-new") + assert row.display_name == "Maewix Studios" + assert row.status == "some_platform_word" + assert row.tier_names == ["Sketches"] + assert row.amount_cents == 500 + assert row.details == {"raw": 1} + assert row.first_seen_at is not None + + +@pytest.mark.asyncio +async def test_re_observation_preserves_first_seen_and_advances_last_seen(db): + """The whole point of the table. + + Committed between touches deliberately: `func.now()` is the TRANSACTION + timestamp in Postgres, so two touches in one transaction would share a + last_seen_at and this test would pass without proving anything. (That + sharing is correct for a sweep — every row it touches is one observation — + but it makes an in-transaction assertion vacuous.) + """ + await touch_membership( + db, platform="patreon", external_campaign_id="c-again", status="active_ish", + ) + await db.commit() + original = await _row(db, campaign="c-again") + first_seen, first_last_seen = original.first_seen_at, original.last_seen_at + db.expunge_all() + + await touch_membership( + db, platform="patreon", external_campaign_id="c-again", status="former_ish", + ) + await db.commit() + + row = await _row(db, campaign="c-again") + assert row.first_seen_at == first_seen, "first_seen_at must never move" + assert row.last_seen_at >= first_last_seen + # Status is last-writer-wins: a lapse has to be able to overwrite. + assert row.status == "former_ish" + + +@pytest.mark.asyncio +async def test_re_observation_overwrites_the_mutable_fields(db): + """A creator who renames, retiers or changes price must not leave the + roster asserting the old value — every column except first_seen_at moves.""" + await touch_membership( + db, platform="patreon", external_campaign_id="c-mut", + display_name="Old Name", amount_cents=500, tier_names=["Cheap"], + details={"v": 1}, + ) + await db.commit() + db.expunge_all() + + await touch_membership( + db, platform="patreon", external_campaign_id="c-mut", + display_name="New Name", amount_cents=1500, tier_names=["Pricey"], + details={"v": 2}, + ) + await db.commit() + + row = await _row(db, campaign="c-mut") + assert row.display_name == "New Name" + assert row.amount_cents == 1500 + assert row.tier_names == ["Pricey"] + assert row.details == {"v": 2} + + +@pytest.mark.asyncio +async def test_the_same_campaign_id_on_two_platforms_is_two_rows(db): + """The key is (platform, external_campaign_id). Nothing stops two platforms + minting the same opaque id, and collapsing them would merge one creator's + membership into another's.""" + await touch_membership(db, platform="patreon", external_campaign_id="shared-id") + await touch_membership(db, platform="subscribestar", external_campaign_id="shared-id") + await db.commit() + + rows = (await db.execute( + select(PlatformMembership).where( + PlatformMembership.external_campaign_id == "shared-id" + ) + )).scalars().all() + assert {r.platform for r in rows} == {"patreon", "subscribestar"} + + +@pytest.mark.asyncio +async def test_a_free_follow_keeps_absent_distinct_from_zero(db): + """"Free" and "we don't know what this costs" are different answers, and + the reconciliation UI has to be able to tell them apart.""" + await touch_membership( + db, platform="patreon", external_campaign_id="c-free", status="free_ish", + ) + await db.commit() + + row = await _row(db, campaign="c-free") + assert row.amount_cents is None + assert row.tier_names is None + + +# --- has_paid_access: unknown must never read as "lost access" ------------- + + +def test_unknown_status_is_unknown_not_false(): + """The dangerous case. False means "the operator lost access", which C4 + turns into an offer to disable the source. Asserting that from a word this + code simply has not been taught would tell them to cancel a subscription + they are still paying for.""" + assert has_paid_access("patreon", "a_word_nobody_characterised_yet") is None + + +def test_absent_status_is_unknown(): + assert has_paid_access("patreon", None) is None + + +def test_unknown_platform_is_unknown(): + assert has_paid_access("a-platform-with-no-mapping", "active") is None + + +def test_the_status_map_starts_empty_and_that_is_deliberate(): + """Guards project rule 130 at the one place it is easiest to break. + + Every entry must come from a characterised response (step C0), never from + API docs or a plausible-looking guess. If this assertion fails, either C0 + happened — in which case update this test along with the map, citing the + capture — or somebody guessed, which is the thing the rule exists to stop. + """ + assert MEMBERSHIP_STATUS == {} + + +def test_the_map_is_consulted_once_it_has_entries(monkeypatch): + """The map is empty today, so exercise the lookup with a stand-in — proving + the plumbing works without pretending to know a real platform's word.""" + monkeypatch.setitem( + MEMBERSHIP_STATUS, "testplat", {"paying": True, "lapsed": False}, + ) + assert has_paid_access("testplat", "paying") is True + assert has_paid_access("testplat", "lapsed") is False + assert has_paid_access("testplat", "something_else") is None