TEASEDocs
ProductsClubConcepts

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.py is a pure, 4-stage resolver: cid first-touch → mapped source_linkpendingorganic. 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:

    1. cid first-touch — the fan's first click carried a cid that resolves to one of your domains.
    2. stored link (fan_source.kind == 'link') — a source_link rule already matched.
    3. campaign — the OF tracking link the fan came through is a confirmed campaign (of_campaigns.source_state == 'known').
    4. 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.
    5. LLM guess (fan_source.kind == 'llm', confidence ≥ 0.6) — the DM-scan's best guess, ranked below every OF-native signal.
    6. pending — a stored pending mapping, or an unresolved trial/claimer/entry signal: traffic is known, the source isn't yet.
    7. checking — a fresh paying fan the engine hasn't finished an event-driven source check on yet (see Four buckets below).
    8. organic — the honest remainder: no signal at any stage.

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:

BucketMeaning
A source rowResolved to a real domain you own via one of the OF-native stages above.
organicPaid, but carried no signal at any stage — the honest remainder.
pendingTraffic is known (a link, a trial) but the source isn't mapped yet. Never folded into organic.
checkingA 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:

TierMeaning
hardA click cid, a mapped link, or a campaign — data, not a guess.
llmA DM-scan guess (confidence ≥ 0.6).
noneThe sentinel buckets (organic / pending / checking) — no evidence to grade.
mixedA 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.

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_disjointfans_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

On this page