feat: platform_membership — the learned roster of what the account pays for (milestone 387 step C1)
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
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
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
"""platform_membership — the learned roster of what the account actually pays for.
|
||||
|
||||
Milestone 387, phase C. FC knows which creators it was told to follow and
|
||||
nothing about which ones the operator is subscribed to; this table is the
|
||||
memory that makes the drift in both directions observable. See the model
|
||||
docstring for why the roster is learned rather than looked up live, and why
|
||||
`status` holds the platform's own word rather than a normalised FC value.
|
||||
|
||||
## Nothing populates this yet, on purpose
|
||||
|
||||
The sweep that fills it (C3) depends on a client seam (C2) that depends on
|
||||
characterising Patreon's real membership response from a captured sample (C0),
|
||||
which needs the operator's authenticated browser session. The table's SHAPE
|
||||
does not wait on that: it is deliberately free-form where C0's findings would
|
||||
otherwise dictate a column — `status` is an unconstrained String and `details`
|
||||
keeps the raw payload — so no capture can invalidate what is created here.
|
||||
|
||||
An empty table is the correct intermediate state. It is not dead code: C5 reads
|
||||
it to explain a tier-limited source, and C4 reads it to reconcile.
|
||||
|
||||
Revision ID: 0091
|
||||
Revises: 0090
|
||||
Create Date: 2026-09-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0091"
|
||||
down_revision: Union[str, None] = "0090"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"platform_membership",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("platform", sa.String(length=64), nullable=False),
|
||||
# Text, not a bounded String: an opaque upstream identifier we do not
|
||||
# mint, and guessing a ceiling for one is how a walk dies on a silent
|
||||
# truncation.
|
||||
sa.Column("external_campaign_id", sa.Text(), nullable=False),
|
||||
sa.Column("display_name", sa.Text(), nullable=True),
|
||||
sa.Column("url", sa.Text(), nullable=True),
|
||||
# No CHECK, deliberately (rule 36 considered and declined): the
|
||||
# vocabulary is each platform's own and is not ours to fix before C0
|
||||
# has characterised even one of them. The service owns the whitelist.
|
||||
sa.Column("status", sa.String(length=32), nullable=True),
|
||||
sa.Column("tier_names", sa.JSON(), nullable=True),
|
||||
sa.Column("amount_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("currency", sa.String(length=8), nullable=True),
|
||||
sa.Column(
|
||||
"first_seen_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"last_seen_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.Column("details", sa.JSON(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_platform_membership")),
|
||||
# The upsert's conflict target. Named explicitly because
|
||||
# touch_membership references it by name in ON CONFLICT — an
|
||||
# autogenerated name would make that call break on a rename nobody
|
||||
# connected to it.
|
||||
sa.UniqueConstraint(
|
||||
"platform", "external_campaign_id",
|
||||
name="uq_platform_membership_platform_campaign",
|
||||
),
|
||||
)
|
||||
# No secondary indexes. This table holds one row per subscription — tens,
|
||||
# not millions — so every query against it is a short scan and an index
|
||||
# would be write cost buying nothing (#3301 removed seven of that shape).
|
||||
# The unique constraint above already backs the only lookup that matters.
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("platform_membership")
|
||||
Reference in New Issue
Block a user