feat: what you pay for, against what FC actually follows (387 C4)
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m18s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m11s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m4s

Reconciliation in Subscriptions, asymmetric on purpose. Subscriptions FC does not follow get a per-row add; sources the roster cannot account for are REPORT ONLY (operator decision) and link to the list on the same page. No one-click disable, so no disabled-reason column and no migration.

The membership<->source join lands as a SHARED resolver in membership_roster, not inline here: E4 now uses it as its negative check, so the two features cannot give different answers to 'is this membership already tracked?'. Keys on the exact cached campaign id FIRST and the URL handle only as fallback, because the id is written only after a source has been walked once. Read via any <platform>_campaign_id override rather than naming Patreon's, per rule 169.

The report-only bucket is gated on roster freshness and carries a per-row basis, so 'your membership says former patron', 'we know this id and it is absent', and 'we only have a handle' stay three different sentences. has_paid_access None never reads as lapsed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
2026-09-11 21:38:03 -04:00
co-authored by Claude Opus 5
parent f8614d437d
commit fc136006b7
9 changed files with 954 additions and 2 deletions
@@ -0,0 +1,144 @@
<template>
<!-- Renders nothing when there is nothing to say same posture as
NeedsAttentionCard. A reconciliation card that always shows would train
the operator to scroll past it. -->
<v-card v-if="anythingToSay" variant="tonal" class="mb-4 fc-recon">
<v-card-text>
<div v-for="p in interesting" :key="p.platform" class="fc-recon__platform">
<!-- Bucket 1: the adoption win. The only direction with an action,
because adding a source is the reversible half. -->
<template v-if="p.subscribed_not_tracked.length">
<div class="fc-recon__head">
<v-icon icon="mdi-account-plus-outline" size="small" class="me-2" />
<strong>
{{ p.subscribed_not_tracked.length }}
{{ p.platform }}
{{ p.subscribed_not_tracked.length === 1 ? 'creator' : 'creators' }}
you subscribe to but don't follow here
</strong>
</div>
<div v-for="m in p.subscribed_not_tracked" :key="m.id" class="fc-recon__row">
<div class="fc-recon__body">
<strong>{{ m.display_name }}</strong>
<span v-if="m.tier_names?.length" class="fc-recon__dim">
· {{ m.tier_names.join(', ') }}
</span>
<!-- A membership whose status this build has not been taught is
still offered, and says so rather than being presented as a
confirmed active subscription. -->
<span v-if="m.paid_access === null" class="fc-recon__dim">
· status not recognised ({{ m.status }})
</span>
</div>
<v-btn size="small" variant="tonal" @click="store.adopt(m.id)">
Add subscription
</v-btn>
</div>
</template>
<!-- Bucket 2: REPORT ONLY. No disable control here by design — the
source list on this same page is where that decision belongs. -->
<template v-if="p.tracked_not_subscribed.length">
<div class="fc-recon__head">
<v-icon icon="mdi-help-circle-outline" size="small" class="me-2" />
<strong>
{{ p.tracked_not_subscribed.length }}
{{ p.platform }}
{{ p.tracked_not_subscribed.length === 1 ? 'source' : 'sources' }}
your roster doesn't account for
</strong>
</div>
<div v-for="s in p.tracked_not_subscribed" :key="s.id" class="fc-recon__row">
<div class="fc-recon__body">
<strong>{{ s.artist.name }}</strong>
<div class="fc-recon__dim">{{ reasonFor(s) }}</div>
</div>
</div>
<div class="fc-recon__dim fc-recon__note">
Nothing has been changed. If you want one of these to stop checking,
disable it in the list below.
</div>
</template>
<!-- The roster could not be trusted, so bucket 2 was not computed. Said
in words: an empty list here must never read as "all clear". -->
<div v-if="!p.fresh" class="fc-recon__dim fc-recon__note">
<v-icon icon="mdi-clock-alert-outline" size="small" class="me-1" />
<template v-if="!p.last_success_at">
Your {{ p.platform }} roster has never synced, so FC can't tell which
sources you still subscribe to.
</template>
<template v-else>
Your {{ p.platform }} roster last synced
{{ formatRelative(p.last_success_at) }}, which is too old to draw
conclusions from.
</template>
Sync it from Settings Subscription roster.
</div>
</div>
</v-card-text>
</v-card>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { formatRelative } from '../../utils/date.js'
import { useMembershipReconcileStore } from '../../stores/membershipReconcile.js'
// #387 C4. Two directions, deliberately unequal: subscriptions FC doesn't
// follow get a one-click add, while sources the roster doesn't account for are
// REPORTED ONLY (operator decision, 2026-09-11). The asymmetry is the point —
// adding a source is trivially undone, and "you no longer subscribe to this" is
// computed from an absence that has three possible causes.
const store = useMembershipReconcileStore()
// An untrustworthy roster is only worth mentioning when there is something it
// would have reconciled — otherwise a failed sweep on a platform with no
// sources yet would put a warning on a page with nothing to warn about.
const interesting = computed(() => store.platforms.filter(
p => p.subscribed_not_tracked.length
|| p.tracked_not_subscribed.length
|| (!p.fresh && p.tracked_total)
))
const anythingToSay = computed(() => interesting.value.length > 0)
function reasonFor (s) {
if (s.basis === 'lapsed') {
return `Your membership reads "${s.membership?.status}" — you're not a paying supporter of this creator right now.`
}
if (s.basis === 'absent_exact') {
return "FC knows this creator's id on the platform, and it isn't in your roster."
}
// absent_handle — the weakest claim, and it says so. A renamed creator looks
// exactly like this, which is why it is not phrased as a conclusion.
return "No membership matched this source's address. It may simply have been renamed."
}
onMounted(() => { store.load() })
</script>
<style scoped>
.fc-recon__platform + .fc-recon__platform {
margin-top: 16px;
}
.fc-recon__head {
display: flex;
align-items: center;
margin: 8px 0 4px;
}
.fc-recon__row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.12);
}
.fc-recon__body { flex: 1; min-width: 0; }
/* Rule 96: dim text is the theme token, never bare opacity. */
.fc-recon__dim {
color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.8125rem;
}
.fc-recon__note { margin-top: 8px; }
</style>
@@ -7,6 +7,9 @@
nothing to say. -->
<NeedsAttentionCard />
<RecentArrivalsCard />
<!-- #387 C4: what you pay for vs. what FC follows. Renders nothing when
the two agree. -->
<MembershipReconcileCard />
<div class="fc-subs__bar">
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
@@ -327,6 +330,7 @@ import { usePlatformsStore } from '../../stores/platforms.js'
import { useImportStore } from '../../stores/import.js'
import NeedsAttentionCard from './NeedsAttentionCard.vue'
import RecentArrivalsCard from './RecentArrivalsCard.vue'
import MembershipReconcileCard from './MembershipReconcileCard.vue'
import SourceRow from './SourceRow.vue'
import SourceCard from './SourceCard.vue'
import SourceHealthDot from './SourceHealthDot.vue'