CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 58s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m47s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m16s
A3 made a tier-gated source say "47 posts you can't see". The roster turns that into a reason: the membership ended, or the tier doesn't reach these posts, or it's a free follow. Rendered under A3's count in the health tooltip, quieter than the count it explains. FREE is a fourth case the step didn't enumerate, and it earns its own sentence. has_paid_access collapses "former patron" and "current free follower" to the same False, so deriving the reason from that boolean would tell a free follower "you're not a patron any more" - a false statement about a state they were never in. gated_reason reads the status axis first, calling has_paid_access with is_free_member forced off, then splits on the free flag. Silence is the default, and there are four ways into it: campaign absent from the roster, roster stale, platform never swept, status word not yet characterised. All four send null and the count stands alone. The frontend has no fallback sentence either - a default would turn "we don't know why" into a reason, which is the one thing this step must not do. The line that must not be crossed is pinned structurally rather than by inspection: test_no_fetch_path_can_read_the_roster walks the transitive first-party imports from the fetch roots and asserts the roster is unreachable. FC runs no local verification (rule 85), so a guard cannot be falsified by hand before it lands - it carries two positive controls instead, proving the walker finds roster imports that ARE there, one direct and one through a hop, so the real assertion can never pass merely because the walk resolved nothing. C4's identity loop moved to membership_roster.pair_sources_with_memberships when C5 became its second caller; two copies would let the Subscriptions row and the reconciliation card disagree about which creator a source IS. Three test files were each building PlatformMembership rows with their own drifting helper - consolidated into tests/roster_builders.py, same family as issue 3109. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
236 lines
9.7 KiB
Python
236 lines
9.7 KiB
Python
"""Reconciling the learned roster against the sources FC actually tracks.
|
|
|
|
Milestone 387, step C4. The step the operator asked for; C0-C3 are what make it
|
|
trustworthy enough to act on.
|
|
|
|
## The buckets
|
|
|
|
1. `subscribed_not_tracked` — you pay for this and FC does not follow it. The
|
|
adoption win, and the only bucket carrying an action.
|
|
2. `tracked_not_subscribed` — FC follows this and the roster does not show you
|
|
paying for it. REPORT ONLY, by the operator's decision (2026-09-11): it says
|
|
what it sees and links to the existing Subscriptions row, and offers no
|
|
one-click disable.
|
|
3. `matched` — the healthy set. Counted, not listed loudly.
|
|
4. `unidentified` — sources this join cannot speak to at all. Reported as
|
|
exactly that, because the alternative is filing them under a verdict.
|
|
|
|
## Why absence is the dangerous direction
|
|
|
|
Bucket 1 is safe to be wrong about: the cost of offering a source the operator
|
|
does not want is one ignored row. Bucket 2 is not. It is computed from an
|
|
ABSENCE — no membership matched — and three different things produce that
|
|
absence: the subscription genuinely lapsed, the sweep failed, or the creator
|
|
renamed and this source has never been walked so no exact id was ever cached.
|
|
|
|
Two guards follow from that, and they are the substance of this module:
|
|
|
|
* the whole bucket is gated on `roster_is_fresh`, so a failed or never-run sweep
|
|
yields an empty list rather than a confident accusation (C3 built the state
|
|
this reads);
|
|
* every row carries the BASIS for its claim, so "your membership says former
|
|
patron" and "we know this creator's id and it is not in your roster" and "we
|
|
only have a URL handle to go on" are three different sentences rather than one
|
|
overconfident one.
|
|
|
|
`has_paid_access` returning None is honoured throughout: unknown is never
|
|
rendered as lapsed. That is the whole reason it returns a tri-state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..models import Artist, MembershipSync, PlatformMembership, Source
|
|
from .membership_roster import (
|
|
get_sync_state,
|
|
has_paid_access,
|
|
identity_keys_for_source,
|
|
pair_sources_with_memberships,
|
|
roster_is_fresh,
|
|
url_tail,
|
|
)
|
|
|
|
# Why a source appears in `tracked_not_subscribed`. Ordered strongest first —
|
|
# the UI renders a different sentence per basis, because collapsing them into
|
|
# one would make the weakest claim sound like the strongest.
|
|
BASIS_LAPSED = "lapsed" # a matched membership says access ended
|
|
BASIS_ABSENT_EXACT = "absent_exact" # exact id known, not in a fresh roster
|
|
BASIS_ABSENT_HANDLE = "absent_handle" # only a URL handle to go on
|
|
|
|
|
|
def _membership_row(m: PlatformMembership) -> dict:
|
|
return {
|
|
"id": m.id,
|
|
"platform": m.platform,
|
|
"external_campaign_id": m.external_campaign_id,
|
|
"display_name": m.display_name or m.vanity_or_none(),
|
|
"url": m.url,
|
|
"vanity": m.vanity_or_none(),
|
|
"status": m.status,
|
|
"tier_names": m.tier_names,
|
|
"amount_cents": m.amount_cents,
|
|
"currency": m.currency,
|
|
"paid_access": has_paid_access(
|
|
m.platform, m.status,
|
|
is_free_member=bool((m.details or {}).get("is_free_member")),
|
|
),
|
|
}
|
|
|
|
|
|
def _source_row(source: Source, artist: Artist) -> dict:
|
|
return {
|
|
"id": source.id,
|
|
"platform": source.platform,
|
|
"url": source.url,
|
|
"enabled": source.enabled,
|
|
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
|
|
}
|
|
|
|
|
|
async def reconcile(
|
|
session: AsyncSession, *, platform: str, now: datetime | None = None,
|
|
) -> dict:
|
|
"""Sort one platform's memberships and sources into the four buckets.
|
|
|
|
Always returns the COMPLETE shape, including when the roster is not fresh —
|
|
a caller reading `len(result["tracked_not_subscribed"])` must not have to
|
|
check which keys exist first. `fresh` is what says whether the emptiness
|
|
means anything.
|
|
"""
|
|
state = await get_sync_state(session, platform)
|
|
fresh = roster_is_fresh(state, now=now)
|
|
|
|
memberships = (await session.execute(
|
|
select(PlatformMembership).where(PlatformMembership.platform == platform)
|
|
)).scalars().all()
|
|
rows = (await session.execute(
|
|
select(Source, Artist)
|
|
.join(Artist, Artist.id == Source.artist_id)
|
|
.where(Source.platform == platform)
|
|
)).all()
|
|
|
|
# The join itself lives in `membership_roster` beside `match_kind`, so C5's
|
|
# gated-reason annotation pairs sources with memberships by exactly the same
|
|
# rule this card sorts them by. Two copies would let the Subscriptions row
|
|
# and this card disagree about which creator a source IS.
|
|
pairs = pair_sources_with_memberships([s for s, _a in rows], memberships)
|
|
matched_membership_ids = {m.id for m, _kind in pairs.values()}
|
|
|
|
subscribed_not_tracked = []
|
|
for m in memberships:
|
|
if m.id in matched_membership_ids:
|
|
continue
|
|
paid = has_paid_access(
|
|
m.platform, m.status,
|
|
is_free_member=bool((m.details or {}).get("is_free_member")),
|
|
)
|
|
# A membership FC knows has ENDED is not an adoption opportunity —
|
|
# adding it would start a walk that can only fetch what is already
|
|
# public. Unknown (None) is still offered: the operator can judge it,
|
|
# and refusing to show it would hide a real subscription behind a word
|
|
# this code has not been taught.
|
|
if paid is False:
|
|
continue
|
|
subscribed_not_tracked.append(_membership_row(m))
|
|
|
|
tracked_not_subscribed = []
|
|
matched = []
|
|
unidentified = []
|
|
for source, artist in rows:
|
|
pair = pairs.get(source.id)
|
|
if pair is not None:
|
|
m, kind = pair
|
|
paid = has_paid_access(
|
|
m.platform, m.status,
|
|
is_free_member=bool((m.details or {}).get("is_free_member")),
|
|
)
|
|
if paid is False:
|
|
if not source.enabled:
|
|
# Already off. Reporting a source the operator has already
|
|
# stopped following is noise, not a finding.
|
|
continue
|
|
row = _source_row(source, artist)
|
|
row["basis"] = BASIS_LAPSED
|
|
row["matched_by"] = kind
|
|
row["membership"] = _membership_row(m)
|
|
tracked_not_subscribed.append(row)
|
|
else:
|
|
row = _source_row(source, artist)
|
|
row["matched_by"] = kind
|
|
row["membership"] = _membership_row(m)
|
|
matched.append(row)
|
|
continue
|
|
|
|
# No membership matched. Whether that MEANS anything depends entirely on
|
|
# how well this source can be identified at all.
|
|
has_exact = bool(identity_keys_for_source(source))
|
|
if not has_exact and url_tail(source.url) is None:
|
|
# Nothing to match on — a sidecar anchor or a URL with no handle.
|
|
# Reported as unidentified rather than silently dropped, so the
|
|
# counts add up to the source list the operator can see.
|
|
unidentified.append(_source_row(source, artist))
|
|
continue
|
|
if not source.enabled:
|
|
# Already off. Telling the operator to stop following something they
|
|
# have stopped following is noise, not a finding.
|
|
continue
|
|
row = _source_row(source, artist)
|
|
row["basis"] = BASIS_ABSENT_EXACT if has_exact else BASIS_ABSENT_HANDLE
|
|
row["matched_by"] = None
|
|
row["membership"] = None
|
|
tracked_not_subscribed.append(row)
|
|
|
|
# THE GATE. Everything above computed the bucket; this decides whether it may
|
|
# be shown. A stale or never-run roster makes every absence meaningless, and
|
|
# an absence rendered as a verdict is how this feature would tell the
|
|
# operator to cancel something they are still paying for.
|
|
if not fresh:
|
|
tracked_not_subscribed = []
|
|
|
|
return {
|
|
"platform": platform,
|
|
"fresh": fresh,
|
|
# How many sources exist on this platform at all. The UI needs it to
|
|
# decide whether an untrustworthy roster is worth mentioning: with no
|
|
# sources here there is nothing to reconcile, and a stale-roster warning
|
|
# would be noise on an install that simply has not started yet (that
|
|
# empty-install case is C6's, not this card's).
|
|
"tracked_total": len(rows),
|
|
"last_success_at": (
|
|
state.last_success_at.isoformat()
|
|
if state is not None and state.last_success_at else None
|
|
),
|
|
"subscribed_not_tracked": subscribed_not_tracked,
|
|
"tracked_not_subscribed": tracked_not_subscribed,
|
|
"matched": matched,
|
|
"unidentified": unidentified,
|
|
}
|
|
|
|
|
|
async def reconcile_all(session: AsyncSession, now: datetime | None = None) -> dict:
|
|
"""Every platform the roster knows about, in one payload for the UI.
|
|
|
|
The platform list is the UNION of platforms with memberships and platforms
|
|
with sync state, not just the former. A sweep that has never succeeded has
|
|
recorded zero memberships, and deriving the list from memberships alone
|
|
would drop exactly that platform from the payload — making a broken
|
|
credential indistinguishable from a platform FC was never asked about. That
|
|
distinction is the whole reason C3 records sync state.
|
|
"""
|
|
with_memberships = (await session.execute(
|
|
select(PlatformMembership.platform).distinct()
|
|
)).scalars().all()
|
|
with_state = (await session.execute(
|
|
select(MembershipSync.platform)
|
|
)).scalars().all()
|
|
platforms = set(with_memberships) | set(with_state)
|
|
return {
|
|
"platforms": [
|
|
await reconcile(session, platform=p, now=now) for p in sorted(platforms)
|
|
]
|
|
}
|