fix: the membership status vocabulary moves beside Membership, so the Patreon client never imports the roster
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 35s
Build images / build-web (push) Successful in 1m20s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m6s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m33s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 35s
Build images / build-web (push) Successful in 1m20s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m6s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m33s
240f11cmade PatreonClient._membership import has_paid_access from membership_roster. test_gated_reason::test_no_fetch_path_can_read_the_roster failed on it, correctly: native_ingest_common is a fetch root, patreon_client is reachable from it, and no fetch path may be able to reach the roster. The roster is allowed to explain a skip, never to cause one. MEMBERSHIP_STATUS and has_paid_access are pure platform knowledge with no database behind them. They move to native_ingest_common, next to the Membership type they interpret (the same move C7 made for Membership itself). membership_roster, membership_reconcile, patreon_client and the tests import them from there. There is no re-export from membership_roster. The guard is unchanged. The lapsed-orphan skip from240f11cstays as it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
This commit is contained in:
@@ -47,12 +47,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from ..models import Artist, MembershipSync, PlatformMembership, Source
|
from ..models import Artist, MembershipSync, PlatformMembership, Source
|
||||||
from .membership_roster import (
|
from .membership_roster import (
|
||||||
get_sync_state,
|
get_sync_state,
|
||||||
has_paid_access,
|
|
||||||
identity_keys_for_source,
|
identity_keys_for_source,
|
||||||
pair_sources_with_memberships,
|
pair_sources_with_memberships,
|
||||||
roster_is_fresh,
|
roster_is_fresh,
|
||||||
url_tail,
|
url_tail,
|
||||||
)
|
)
|
||||||
|
from .native_ingest_common import has_paid_access
|
||||||
|
|
||||||
# Why a source appears in `tracked_not_subscribed`. Ordered strongest first —
|
# Why a source appears in `tracked_not_subscribed`. Ordered strongest first —
|
||||||
# the UI renders a different sentence per basis, because collapsing them into
|
# the UI renders a different sentence per basis, because collapsing them into
|
||||||
|
|||||||
@@ -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
|
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
|
be corrected without a migration, because the vocabulary comes from whatever
|
||||||
each platform says and will be discovered per platform rather than designed up
|
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
|
front. `native_ingest_common.MEMBERSHIP_STATUS` is where that knowledge
|
||||||
platforms are characterised; it is deliberately empty of guesses today.
|
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
|
from __future__ import annotations
|
||||||
@@ -35,78 +37,10 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..models import MembershipSync, PlatformMembership, Source
|
from ..models import MembershipSync, PlatformMembership, Source
|
||||||
|
from .native_ingest_common import has_paid_access
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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(
|
async def touch_membership(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ class Membership:
|
|||||||
|
|
||||||
`status` carries the PLATFORM's own word, verbatim and unmapped
|
`status` carries the PLATFORM's own word, verbatim and unmapped
|
||||||
(`active_patron`, `former_patron`, ...). Deciding what it means is the read
|
(`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.
|
unrecognised word records as evidence rather than as a decision.
|
||||||
|
|
||||||
`is_free_member` is SEPARATE from status and must stay that way. Patreon
|
`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 = media_path.with_suffix(".json")
|
||||||
sidecar_path.write_text(json.dumps(data, indent=2))
|
sidecar_path.write_text(json.dumps(data, indent=2))
|
||||||
return sidecar_path
|
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
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ from .native_ingest_common import (
|
|||||||
NativeDriftError,
|
NativeDriftError,
|
||||||
NativeIngestError,
|
NativeIngestError,
|
||||||
basename_from_url,
|
basename_from_url,
|
||||||
|
has_paid_access,
|
||||||
make_session,
|
make_session,
|
||||||
retry_after_seconds,
|
retry_after_seconds,
|
||||||
)
|
)
|
||||||
@@ -650,8 +651,6 @@ class PatreonClient:
|
|||||||
membership without a creator is something FC cannot vouch for, and
|
membership without a creator is something FC cannot vouch for, and
|
||||||
dropping it would read downstream as a cancellation.
|
dropping it would read downstream as a cancellation.
|
||||||
"""
|
"""
|
||||||
from .membership_roster import has_paid_access
|
|
||||||
|
|
||||||
attrs = member.get("attributes") or {}
|
attrs = member.get("attributes") or {}
|
||||||
if "patron_status" not in attrs:
|
if "patron_status" not in attrs:
|
||||||
raise PatreonDriftError(
|
raise PatreonDriftError(
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ _ROSTER_URL = f"{_ROSTER_BASE}/subscriptions"
|
|||||||
# `data-identifier`, the one vocabulary that names a state: the table class
|
# `data-identifier`, the one vocabulary that names a state: the table class
|
||||||
# inside the cancelled card says `for-unsubscribed_users`, a different word for
|
# 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
|
# 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_ACTIVE = "active_subscriptions"
|
||||||
_ROSTER_CANCELLED = "cancelled_subscriptions"
|
_ROSTER_CANCELLED = "cancelled_subscriptions"
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ import pytest
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from backend.app.models import PlatformMembership
|
from backend.app.models import PlatformMembership
|
||||||
from backend.app.services.membership_roster import (
|
from backend.app.services.membership_roster import touch_membership
|
||||||
MEMBERSHIP_STATUS,
|
from backend.app.services.native_ingest_common import MEMBERSHIP_STATUS, has_paid_access
|
||||||
has_paid_access,
|
|
||||||
touch_membership,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from backend.app.services.membership_roster import has_paid_access, roster_user_id
|
from backend.app.services.membership_roster import roster_user_id
|
||||||
from backend.app.services.native_ingest_common import Membership
|
from backend.app.services.native_ingest_common import Membership, has_paid_access
|
||||||
from backend.app.services.subscribestar_client import (
|
from backend.app.services.subscribestar_client import (
|
||||||
SubscribeStarAuthError,
|
SubscribeStarAuthError,
|
||||||
SubscribeStarClient,
|
SubscribeStarClient,
|
||||||
|
|||||||
Reference in New Issue
Block a user