diff --git a/backend/app/services/membership_reconcile.py b/backend/app/services/membership_reconcile.py index c6a0d44..ce5b057 100644 --- a/backend/app/services/membership_reconcile.py +++ b/backend/app/services/membership_reconcile.py @@ -47,12 +47,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..models import Artist, MembershipSync, PlatformMembership, Source from .membership_roster import ( get_sync_state, - has_paid_access, identity_keys_for_source, pair_sources_with_memberships, roster_is_fresh, url_tail, ) +from .native_ingest_common import has_paid_access # Why a source appears in `tracked_not_subscribed`. Ordered strongest first — # the UI renders a different sentence per basis, because collapsing them into diff --git a/backend/app/services/membership_roster.py b/backend/app/services/membership_roster.py index 8cf4945..e36cc50 100644 --- a/backend/app/services/membership_roster.py +++ b/backend/app/services/membership_roster.py @@ -20,8 +20,10 @@ 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. +front. `native_ingest_common.MEMBERSHIP_STATUS` is where that knowledge +accumulates as platforms are characterised, and it holds no guesses. It lives +there rather than here because platform clients need it, and a client may not +import this module (test_gated_reason.py). """ from __future__ import annotations @@ -35,78 +37,10 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from ..models import MembershipSync, PlatformMembership, Source +from .native_ingest_common import has_paid_access log = logging.getLogger(__name__) -# Platform word -> whether the account currently has paid access. -# -# Every entry here must come from a CHARACTERISED response, never from API docs -# or a plausible guess — project rule 130, and inventing a status before seeing -# it in a real payload is exactly the failure it names. -# -# patreon: from a live capture of the operator's own session, 2026-09-10 -# (Scribe note #3886). Only two values were OBSERVED in `patron_status` and -# only those two are here. -# -# `declined_patron` is deliberately ABSENT even though it looks obviously -# right. It appears in the request's `filter[membership_type]`, and the capture -# proved that filter is NOT the same vocabulary as the attribute — a row -# selected by the filter as `free_member` came back with -# `patron_status: former_patron`, a word the filter does not contain. Reading -# the filter as an enum is the specific mistake the capture caught; adding -# `declined_patron` on the strength of it would be repeating that mistake one -# step later. -# -# 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. -# -# subscribestar: from a live capture of the account's /subscriptions page, -# 2026-09-13 (Scribe note #3989). SubscribeStar gives NO per-row status word — -# a membership's state is which of two tables it sits in — so the "word" stored -# is the table card's own `data-identifier`, verbatim. Those two identifiers are -# the whole vocabulary; there is nothing further to characterise later. -MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = { - "patreon": { - "active_patron": True, - "former_patron": False, - }, - "subscribestar": { - "active_subscriptions": True, - "cancelled_subscriptions": False, - }, -} - - -def has_paid_access( - platform: str, status: str | None, *, is_free_member: bool = False, -) -> bool | None: - """Does this membership 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. - - `is_free_member` is a second axis, not a status, and that is Patreon's - design rather than ours: the capture shows a free follow expressed as a - boolean alongside `patron_status`, so a "current" membership can still be - one nobody is paying for. Taking status alone would report a free follower - as a paying patron, and C4 would then never offer to clean it up. - - (Honest limit: the capture contains no ACTIVE free member, so it cannot - demonstrate the two axes coming apart. The separation is what the payload's - shape says; the sample only shows it is possible, not that it happens.) - """ - if status is None: - return None - known = MEMBERSHIP_STATUS.get(platform, {}).get(status) - if known is None: - return None - if not known: - return False - return not is_free_member - async def touch_membership( session: AsyncSession, diff --git a/backend/app/services/native_ingest_common.py b/backend/app/services/native_ingest_common.py index 7731163..29226b0 100644 --- a/backend/app/services/native_ingest_common.py +++ b/backend/app/services/native_ingest_common.py @@ -231,7 +231,7 @@ class Membership: `status` carries the PLATFORM's own word, verbatim and unmapped (`active_patron`, `former_patron`, ...). Deciding what it means is the read - site's job — `membership_roster.has_paid_access` — precisely so an + site's job — `has_paid_access`, below — precisely so an unrecognised word records as evidence rather than as a decision. `is_free_member` is SEPARATE from status and must stay that way. Patreon @@ -396,3 +396,81 @@ class BaseNativeDownloader: sidecar_path = media_path.with_suffix(".json") sidecar_path.write_text(json.dumps(data, indent=2)) return sidecar_path + +# --- membership status vocabulary (#387) ------------------------------------ +# +# Lives here, beside `Membership`, rather than in `membership_roster`. It is +# pure platform knowledge with no database behind it, and the platform clients +# need it too. Patreon's must tell a lapsed membership to a deleted creator +# (skippable) from a paid one it cannot attribute (drift), and a client may not +# import `membership_roster`: test_gated_reason.py forbids any fetch path from +# reaching the roster, so the roster can explain a skip but never cause one. +# +# Platform word -> whether the account currently has paid access. +# +# Every entry here must come from a CHARACTERISED response, never from API docs +# or a plausible guess — project rule 130, and inventing a status before seeing +# it in a real payload is exactly the failure it names. +# +# patreon: from a live capture of the operator's own session, 2026-09-10 +# (Scribe note #3886). Only two values were OBSERVED in `patron_status` and +# only those two are here. +# +# `declined_patron` is deliberately ABSENT even though it looks obviously +# right. It appears in the request's `filter[membership_type]`, and the capture +# proved that filter is NOT the same vocabulary as the attribute — a row +# selected by the filter as `free_member` came back with +# `patron_status: former_patron`, a word the filter does not contain. Reading +# the filter as an enum is the specific mistake the capture caught; adding +# `declined_patron` on the strength of it would be repeating that mistake one +# step later. +# +# 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. +# +# subscribestar: from a live capture of the account's /subscriptions page, +# 2026-09-13 (Scribe note #3989). SubscribeStar gives NO per-row status word — +# a membership's state is which of two tables it sits in — so the "word" stored +# is the table card's own `data-identifier`, verbatim. Those two identifiers are +# the whole vocabulary; there is nothing further to characterise later. +MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = { + "patreon": { + "active_patron": True, + "former_patron": False, + }, + "subscribestar": { + "active_subscriptions": True, + "cancelled_subscriptions": False, + }, +} + + +def has_paid_access( + platform: str, status: str | None, *, is_free_member: bool = False, +) -> bool | None: + """Does this membership 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. + + `is_free_member` is a second axis, not a status, and that is Patreon's + design rather than ours: the capture shows a free follow expressed as a + boolean alongside `patron_status`, so a "current" membership can still be + one nobody is paying for. Taking status alone would report a free follower + as a paying patron, and C4 would then never offer to clean it up. + + (Honest limit: the capture contains no ACTIVE free member, so it cannot + demonstrate the two axes coming apart. The separation is what the payload's + shape says; the sample only shows it is possible, not that it happens.) + """ + if status is None: + return None + known = MEMBERSHIP_STATUS.get(platform, {}).get(status) + if known is None: + return None + if not known: + return False + return not is_free_member diff --git a/backend/app/services/patreon_client.py b/backend/app/services/patreon_client.py index 11bb7da..07e2c9c 100644 --- a/backend/app/services/patreon_client.py +++ b/backend/app/services/patreon_client.py @@ -58,6 +58,7 @@ from .native_ingest_common import ( NativeDriftError, NativeIngestError, basename_from_url, + has_paid_access, make_session, retry_after_seconds, ) @@ -631,7 +632,25 @@ class PatreonClient: "cannot tell a complete roster from a truncated one" ) - def _membership(self, member: dict, index: dict) -> Membership: + def _membership(self, member: dict, index: dict) -> Membership | None: + """One member row as a Membership, or None for a row the roster can skip. + + The one skippable row is a LAPSED membership whose creator no longer + exists. The live roster (note #3886, CORRECTION 3) returned 104 rows, + because FC sends no membership-type filter and so gets lapses going back + years. One of them, a membership that ended in 2017, carried no + `campaign` relationship at all: the key is absent, not null, and its + reward names no campaign either. The creator's page is gone. + + Raising on that row made the whole roster unusable over one membership + nobody can act on. Skipping it changes no conclusion. A lapsed + membership already means "not paying", absence means the same, and no + Source can be matched to a campaign that no longer has an id. + + The refusal stays for every other row. An active or unrecognised + membership without a creator is something FC cannot vouch for, and + dropping it would read downstream as a cancellation. + """ attrs = member.get("attributes") or {} if "patron_status" not in attrs: raise PatreonDriftError( @@ -640,6 +659,17 @@ class PatreonClient: campaign_ids = self._related_ids(member, "campaign") if not campaign_ids: + paid = has_paid_access( + "patreon", attrs.get("patron_status"), + is_free_member=bool(attrs.get("is_free_member")), + ) + if paid is False: + log.info( + "Patreon roster: skipping a lapsed membership with no campaign " + "(creator deleted); status=%s access_expires_at=%s", + attrs.get("patron_status"), attrs.get("access_expires_at"), + ) + return None raise PatreonDriftError( "Patreon member resource has no campaign relationship — a " "membership we cannot attribute to a creator is not usable" @@ -700,7 +730,9 @@ class PatreonClient: index = self._transform(response) rows = [m for m in (response.get("data") or []) if isinstance(m, dict)] for member in rows: - yield self._membership(member, index) + membership = self._membership(member, index) + if membership is not None: + yield membership seen += len(rows) total = int(response["meta"]["pagination"]["total"] or 0) diff --git a/backend/app/services/subscribestar_client.py b/backend/app/services/subscribestar_client.py index e51e026..9ae848a 100644 --- a/backend/app/services/subscribestar_client.py +++ b/backend/app/services/subscribestar_client.py @@ -316,7 +316,7 @@ _ROSTER_URL = f"{_ROSTER_BASE}/subscriptions" # `data-identifier`, the one vocabulary that names a state: the table class # inside the cancelled card says `for-unsubscribed_users`, a different word for # the same list (note #3989, CORRECTION 1). The identifier is stored verbatim as -# Membership.status and mapped in membership_roster.MEMBERSHIP_STATUS. +# Membership.status and mapped in native_ingest_common.MEMBERSHIP_STATUS. _ROSTER_ACTIVE = "active_subscriptions" _ROSTER_CANCELLED = "cancelled_subscriptions" diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 9cb1c69..d2d1914 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -15,7 +15,10 @@ Colours are theme tokens (frontend/src/theme/fabled-tokens.js): obsidian plate, accent gold. The plate is kept here (unlike logo.svg) so the tab icon is self-contained against any browser chrome; on the nav it is - invisible because it matches --fc-chrome-rgb exactly. --> + invisible because it matches the fc-chrome-rgb custom property exactly. + No double hyphen may appear inside this comment: XML forbids it, and a + browser refuses to render an SVG that does not parse (it happened once — + tests/test_public_svgs.py). --> diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 2123194..24022f6 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -17,6 +17,22 @@ const route = useRoute()