Source attribution
How Club decides where a paying fan came from — the priority ladder, the four money buckets, and what the reconciliation numbers actually prove.
Every paying fan is placed in exactly one source bucket. This page is the mental model — the how-to pages under How-to cover the actions (confirming a campaign, fixing a fan, reading the leaderboard).
The priority ladder
Club resolves a fan to a source by walking a fixed list of signals and stopping at the first one that matches. Two different pieces of code implement two different lengths of this ladder, and they disagree on purpose:
-
core/source/resolver.pyis a pure, 4-stage resolver:cidfirst-touch → mappedsource_link→pending→organic. Its own docstring calls it "ONE source of truth" — that claim is only true for the stages it actually computes. -
core/source_leaderboard/attribute.py(_lb_attribute), which builds the money leaderboard every dashboard reads, layers more stages on top:- cid first-touch — the fan's first click carried a
cidthat resolves to one of your domains. - stored link (
fan_source.kind == 'link') — asource_linkrule already matched. - campaign — the OF tracking link the fan came through is a confirmed campaign
(
of_campaigns.source_state == 'known'). - D3b code lookup — an additive backstop: a code carried in the fan's history is known to map to a source, even without a full campaign match.
- LLM guess (
fan_source.kind == 'llm', confidence ≥ 0.6) — the DM-scan's best guess, ranked below every OF-native signal. - pending — a stored
pendingmapping, or an unresolved trial/claimer/entry signal: traffic is known, the source isn't yet. - checking — a fresh paying fan the engine hasn't finished an event-driven source check on yet (see Four buckets below).
- organic — the honest remainder: no signal at any stage.
- cid first-touch — the fan's first click carried a
Don't document the ladder from resolver.py alone
resolver.py is real and correct for what it computes, but reading only its docstring
under-scopes the system: it doesn't implement campaign/D3b/LLM/checking at all, and it
explicitly keeps KIND_LLM out of its own ranking. The leaderboard's _lb_attribute is the
ladder that actually decides what a dashboard shows.
Four buckets
The leaderboard (compute_source_leaderboard) always returns four top-level rows — sources
(one entry per resolved domain), organic, pending, and checking — never just three:
| Bucket | Meaning |
|---|---|
| A source row | Resolved to a real domain you own via one of the OF-native stages above. |
| organic | Paid, but carried no signal at any stage — the honest remainder. |
| pending | Traffic is known (a link, a trial) but the source isn't mapped yet. Never folded into organic. |
| checking | A brand-new paying fan the engine's event-driven source check hasn't settled yet — a transient state, never written into fan_source itself. |
The old attribution guide is missing a bucket
A previous version of this documentation described only three buckets (sources / organic /
pending). checking is a real fourth top-level key on the leaderboard response, with its own
checking_net_cents / checking_subs counters. See
Doc gaps for the full list of
places the old guide drifted from the current code.
Evidence tiers
Independently of which bucket a source row lands in, every dollar inside it is tagged by how it was attributed:
| Tier | Meaning |
|---|---|
hard | A click cid, a mapped link, or a campaign — data, not a guess. |
llm | A DM-scan guess (confidence ≥ 0.6). |
none | The sentinel buckets (organic / pending / checking) — no evidence to grade. |
mixed | A row (after display-folding) contains both hard and LLM money. |
A row's evidence_level is derived purely from its own hard/llm cents split — it never moves
money between buckets, so a UI can show "confirmed" vs "inferred" revenue without touching the
totals underneath.
SourceLink mapping rules
A source_link row is the owner's standing decision that one OnlyFans link identity should
always resolve to one real domain-source. It matches by provider_id, exact_name,
name_prefix, or regex, evaluated in ascending priority (then by row id) — the first active
match wins. A mapping can never target the __organic__ or __pending__ sentinels; the create/
update endpoints reject that with a 400. See
Create a source-link rule.
Source-layout onboarding
After a scan populates of_campaigns, every tracking link the engine found gets a proposed
source by name (propose_source: rule match → source keyword → domain-name match → nothing).
The owner confirms — per campaign or in bulk — which writes a source_link rule and
re-attributes only that campaign's existing fans. A full re-resolve of every fan is deliberately
never triggered from this path: it holds a database write lock long enough to 500 concurrent
writes. See Confirm a scanned campaign's source.
The reconciliation invariant — and what it doesn't prove
Every leaderboard response carries a reconciliation object with a balanced flag. Read it
carefully:
balanced is guaranteed by construction
sources_net + organic_net + pending_net + checking_net == total_net_cents is always
true, because any leftover money that didn't land in a bucket during attribution is
deliberately dumped into organic as a residual (_lb_residuals). The code's own comment
calls this a tautology, not proof of correctness. balanced: false can still happen (it
means the window was still settling), but balanced: true does not by itself prove no fan
was double-counted.
The real per-fan integrity check is reconciliation.fans_disjoint —
fans_counted == fans_distinct, i.e. every fan appears in exactly one bucket's fan set. If a
fan is ever double-counted, fans_disjoint goes false and the backend logs a warning;
balanced alone would never catch it.
First-touch, not last-touch
core/country_revenue.py credits a fan's entire lifetime revenue to the country/campaign of
their first touch, never the last, and never split across touches — an explicit owner
decision (2026-06-26). Don't change this to last-touch or a split without a direct owner call;
it's load-bearing for compute_campaign_revenue, which several other modules (free-pool
resurfacing, campaign resurfacing) depend on to answer "has this code ever earned money."
What's next
- Read revenue by traffic source — the leaderboard endpoint and its query parameters.
- Confirm a scanned campaign's source
- Drill a source down to paying fans
Money & Revenue
The rules every dollar in Club obeys — net cents, what counts as a purchase, money-kind groups, the fail-closed money mask, and the two revenue sources (OnlyFans/Fansly transactions and Tribute).
Vault & content concepts
The mental model behind the content vault — what the index actually is, how OnlyFans writes are staged, and the shared truths content endpoints all reuse.