Files
FabledCurator/backend/app/services/membership_roster.py
T
bvandeusenandClaude Opus 5 afcde8e457
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 58s
Build images / build-web (push) Successful in 1m16s
Build images / smoke-web (push) Skipped
CI / integration (push) Successful in 2m25s
Build images / build-ml (push) Successful in 2m45s
Build images / promote (push) Skipped
feat: PatreonClient.iter_memberships — the roster seam (milestone 387 step C2)
Built on C0's real capture (Scribe note #3886), not on API docs — gallery-dl
has no membership extractor and Patreon's public v2 API is the CREATOR surface
behind OAuth, so the rule-130 reference had to be a characterized response.

**The request is deliberately minimal, and that is a privacy decision.** The
browser's own include set pulls `latest_pledge.card`, and those card resources
come back carrying the ACCOUNT HOLDER'S EMAIL in `merchant_name`; `address` is
in there too. Copying the query string wholesale is the obvious move and would
have FC fetching payment PII it has no use for and can only mishandle. We ask
for `include=campaign,reward` and nothing else, and a test asserts on the
params actually sent so nobody widens it back.

**We do not send `filter[membership_type]`.** The browser sends the six buckets
its settings page displays, which excludes lapsed memberships — and a
DISAPPEARANCE is precisely the signal the roster exists to read. Filtering here
would manufacture the event C4 acts on.

Two corrections the capture forced, both now in code:

* **The filter vocabulary is not the status vocabulary.** I had read the six
  filter words off a screenshot and was about to write them into
  MEMBERSHIP_STATUS as the enum. The body shows `patron_status` carrying
  `former_patron` — absent from that filter — on a row the filter selected as
  `free_member`. So the map is taught exactly the two OBSERVED values, and
  `declined_patron` stays out despite looking obviously right: believing the
  filter is the mistake that was just caught.
* **Free membership is a boolean, not a status.** `has_paid_access` gains an
  `is_free_member` axis, because `active_patron` alone would report a free
  follower as a paying patron and C4 would never offer to clean it up. Honest
  limit, stated in the docstring: the capture has no ACTIVE free member, so it
  shows the separation is possible, not that it occurs.

C1's tripwire test did its job — it was written to fail the moment anyone
populated the status map, and updating it here IS the confirmation step, done
with the capture rather than ahead of it.

`_fetch`'s retry/backoff/auth-vs-drift/Retry-After logic is extracted to a
shared `_request` so the roster rides the same path rather than growing a
second copy — two copies would drift, and the half that drifted would be the
one that only runs daily. Every error message and log line renders
byte-identically for the posts path, so the existing tests pin the refactor.

Pagination is driven by `page[offset]` against `meta.pagination.total`, never
by `links`: the response's own `links.first` is built WITHOUT the `/api/`
prefix the request uses, so following it would hit the web page. An empty page
is terminal regardless of what the total claims, so a server reporting more
rows than it hands over cannot spin the walk forever.

Drift is stricter here than on the posts path, on purpose: a missing
`meta.pagination.total` raises rather than returning a short list, because a
truncated roster reads downstream as "you cancelled those" — the worst wrong
answer this feature can give.

`current_user_id()` is marked INFERRED, not characterized: C0 captured
/api/members, not /api/current_user, so it relies only on the JSON:API envelope
this API demonstrably uses elsewhere, and raises drift rather than returning
something plausible if that is wrong.

The fixture is derived from the real capture with every piece of account data
replaced (the raw capture stays gitignored). Six members, each earning its
place: a former patron with a null pledge, an active patron with no tier, an
annual cadence, a previous_pledge whose included resource has no
`relationships` key at all, and a reward priced in CAD beside a USD charge —
the trap that makes reading `reward.amount_cents` report a number the operator
was never charged. A leak check caught a free-membership-subscription id and
six real campaign launch timestamps before any of it was staged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
2026-09-10 22:26:01 -04:00

149 lines
6.1 KiB
Python

"""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.
#
# 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.
MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = {
"patreon": {
"active_patron": True,
"former_patron": 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,
*,
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)