CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 39s
Build images / build-web (push) Successful in 1m14s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m18s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m21s
FC knows which creators it was TOLD to follow and nothing about which ones the operator is subscribed to. Those two sets drift both ways and neither drift is currently visible: a subscription FC doesn't track is content the operator believes they're archiving and aren't, and a source walked after the subscription lapsed is requests spent on a wall reported as a creator gone quiet. Sibling of service_seen (milestone 365) and the same insight — an absence is only observable against a record of presence. touch_membership reuses the recorded touch_service shape (snippet 3447): upsert rather than read-modify-write, first_seen_at deliberately outside the update set because it's the one field that makes a later DISAPPEARANCE readable as a lapse rather than as a creator we never knew. Nothing populates it yet, and that's the intended intermediate state. The sweep (C3) needs a client seam (C2) that needs Patreon's real response characterised from a captured sample (C0), which needs the operator's browser session. The table's SHAPE doesn't wait on that, because it's deliberately free-form exactly where C0's findings would otherwise dictate a column. status is an unconstrained String holding the PLATFORM's own word, not a normalised FC value. Rule 36 considered and declined, same reasoning service_seen.kind records: the vocabulary isn't ours to invent, and picking a lowest-common-denominator enum before any platform has been characterised would bake a guess into the schema. The service owns the whitelist and the mapping; the column owns the evidence. MEMBERSHIP_STATUS ships EMPTY, guarded by a test that fails if anyone adds an entry — every one must come from a characterised response, not from API docs. That's rule 130 at the one place it's easiest to break, and the failure message says so. has_paid_access returns None, never False, for a word it hasn't been taught. The difference is load-bearing: False means the operator lost access, which C4 turns into an offer to disable the source, so asserting it from an unrecognised word would tell them to cancel a subscription they're still paying for. Retention decided here rather than deferred (rule 89): a membership that stops appearing is aged out on time, never deleted on absence — deleting would destroy the signal at the moment it became interesting. Rule 90 check, done on the right thing this time: the per-test TRUNCATE teardown derives its table list from Base.metadata.sorted_tables, so the new table is picked up automatically; test_models asserts a subset, so it doesn't break. 0091 follows 0090 on the collapsed baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
117 lines
4.7 KiB
Python
117 lines
4.7 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.
|
|
#
|
|
# 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)
|