From 4533e036ac4c0a919c172abb81501832746bce7f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 12 Sep 2026 20:05:11 -0400 Subject: [PATCH] refactor: the membership seam's contract type is the seam's, not Patreon's (387 C7) Membership moves from patreon_client to native_ingest_common, beside PostRecordOutcome, for exactly the reason that one lives there: it is the seam's contract rather than the first platform's. Left where it was, D1 would have had to import the shape it implements from the module of the platform it is being mirrored FROM - which inverts the dependency and is how a seam advertised as portable quietly stays Patreon-shaped. Found by C7's own pass, which is the point of running C7 before D1 rather than writing it up afterwards: this is invisible while there is only one implementer and load-bearing the moment there are two. No behaviour change. Three files, no shim (rule 122): patreon_client imports it, the dataclass keeps its docstring, and the test imports from the seam's home. The docstring gains what the contract owes a second platform - that a missing field supplies the empty answer and never a guess: no tiers -> [], no pledge -> None (absent stays distinguishable from zero, since "free" and "we don't know" are different answers), no vanity -> None with identity falling back to the URL tail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 --- backend/app/services/native_ingest_common.py | 53 ++++++++++++++++++++ backend/app/services/patreon_client.py | 39 +------------- tests/test_patreon_memberships.py | 2 +- 3 files changed, 55 insertions(+), 39 deletions(-) diff --git a/backend/app/services/native_ingest_common.py b/backend/app/services/native_ingest_common.py index e0c9bda..7731163 100644 --- a/backend/app/services/native_ingest_common.py +++ b/backend/app/services/native_ingest_common.py @@ -211,6 +211,59 @@ class PostRecordOutcome: body_chars: int +# -- membership roster seam (shared dataclass, #387 C2/C7) ----------------- + +@dataclass +class Membership: + """One membership the ACCOUNT holds, as the roster needs it (#387 C2). + + Lives HERE rather than in the platform module that first produced it, for + the same reason `PostRecordOutcome` does: it is the seam's contract, not + Patreon's. C7 moved it — while it sat in `patreon_client` a second platform + would have had to import its contract from the first platform's module, + which inverts the dependency and is how a "portable" seam quietly becomes + Patreon-shaped. + + Deliberately not a raw upstream row: the sweep should not have to know that + a tier lives behind a JSON:API `reward` relationship, and + `platform_membership` should not gain columns because one platform shapes + things a certain way. + + `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 + unrecognised word records as evidence rather than as a decision. + + `is_free_member` is SEPARATE from status and must stay that way. Patreon + expresses a free follow as this boolean rather than as a status value, so + "does the account pay for this" is `status == "active_patron" and not + is_free_member` — a question the status string alone cannot answer. NOTE: + the C0 capture contains no ACTIVE free member, so the two fields are + perfectly correlated in that sample; the separation is what the schema + says, not something the sample proves. + + A platform that lacks a field supplies the empty answer, never a guess: + no tiers -> `[]`, no pledge -> `amount_cents=None` (absent stays + distinguishable from zero — "free" and "we don't know" are different + answers), no vanity -> None and identity falls back to the URL tail. + """ + + campaign_id: str + display_name: str | None + url: str | None + vanity: str | None + status: str | None + is_free_member: bool + tier_names: list[str] + amount_cents: int | None + currency: str | None + # Everything the roster did not model, kept so a later question can be + # answered without another authenticated round-trip. Scoped to the + # membership's own attributes plus the creator's — never the raw page, + # which is where the card/address resources live. + details: dict + + # -- base downloader (shared fetch/validate plumbing) ---------------------- class BaseNativeDownloader: diff --git a/backend/app/services/patreon_client.py b/backend/app/services/patreon_client.py index 7d291d9..11bb7da 100644 --- a/backend/app/services/patreon_client.py +++ b/backend/app/services/patreon_client.py @@ -53,6 +53,7 @@ from ..utils.paths import filehash_from_url from ..utils.prosemirror import post_body_html from .native_ingest_common import ( _MAX_429_RETRIES, + Membership, NativeAuthError, NativeDriftError, NativeIngestError, @@ -163,44 +164,6 @@ class MediaItem: post_id: str -@dataclass -class Membership: - """One membership the ACCOUNT holds, as the roster needs it (#387 C2). - - Deliberately not a raw JSON:API row: the sweep (C3) should not have to know - that a tier lives behind a `reward` relationship, and `platform_membership` - should not gain columns because Patreon shapes things a certain way. - - `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 - unrecognised word records as evidence rather than as a decision. - - `is_free_member` is SEPARATE from status and must stay that way. The - capture shows Patreon expressing a free follow as this boolean rather than - as a status value, so "does the account pay for this" is - `status == "active_patron" and not is_free_member` — a question the status - string alone cannot answer. NOTE: the capture contains no ACTIVE free - member, so the two fields are perfectly correlated in that sample; the - separation is what the schema says, not something the sample proves. - """ - - campaign_id: str - display_name: str | None - url: str | None - vanity: str | None - status: str | None - is_free_member: bool - tier_names: list[str] - amount_cents: int | None - currency: str | None - # Everything the roster did not model, kept so a later question can be - # answered without another authenticated round-trip. Scoped to the member's - # own attributes plus the campaign's — never the raw page, which is where - # the card/address resources live. - details: dict - - def _filehash(url: str) -> str | None: # Delegate to the shared extractor (utils.paths) so capture-time persistence # and render-time inline-image matching use the EXACT same identity. diff --git a/tests/test_patreon_memberships.py b/tests/test_patreon_memberships.py index be2e44f..9612c5f 100644 --- a/tests/test_patreon_memberships.py +++ b/tests/test_patreon_memberships.py @@ -18,8 +18,8 @@ from pathlib import Path import pytest +from backend.app.services.native_ingest_common import Membership from backend.app.services.patreon_client import ( - Membership, PatreonClient, PatreonDriftError, )