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()