Source Attribution
How Heatwave assigns, protects, and reports marketing/sales attribution.
Rebuilt July 2026 (PRs #1676, #1686 + gated production backfills; full
history in doc/tasks/202607231507_SOURCE_ATTRIBUTION_MODEL_RESTRUCTURE.md,
Basecamp todo 10014579025).
The model in one paragraph
Attribution answers three different questions, and Heatwave keeps them
apart: acquisition (how did this customer/deal first find us — the
write-once original_source_id), operational source (the mutable
source_id label used day-to-day), and channel (platform/retailer
volume vs marketing — the sources.category axis). Touches are recorded
as immutable events (visits, audience joins, deliveries, activities);
credit is computed from evidence and windows, never by unbounded
retroactive overwrites.
The source tree
Source is an ltree hierarchy (sources table). Every node carries:
category—marketing,outside_sales,platform_retailer,
referral_partner, or NULL (reported as Uncategorized). Root nodes
were mapped in migration20260724055123; new nodes inherit their
parent's category at creation (Source#inherit_category). Reports
segment by it — marketing ROI reads only the marketing category,
never the sum of all branches.
Campaign sources are categorised by whereCampaign#generate_source
parents them, so the two campaign parents carry the distinction:
Campaigns > EmailandCampaigns > Mailingare marketing (a
newsletter is not a rep making calls),Campaigns > Outside Salesis
outside_sales. Correct the parent, not the individual campaign —
inheritance does the rest.visibility— auto-generated per-blog-post and per-campaign-email
nodes arehidden: valid for historical data, absent from pickers.
Platform partners (Amazon, Wayfair, Costco, Home Depot, Lowe's,
Build.com, Menards, …) are first-class nodes under the Retail /
E-tailer roots (platform_retailer category). An order arriving
through a platform is driven by that platform's marketing, so partner
accounts point at their platform node and their records carry it
structurally.
How a record gets its source (creation)
| Path | Rule |
|---|---|
| Web/cart order or opp with a tracked visit | the visit's source (evidence; set_source_from_visit) |
| Order converted from an opportunity | inherits the opportunity's source — the closing call/email is influence, not acquisition (Order.default_source_for, Quote::ConvertToOrder) |
| Standalone CRM order/opp | customer's recent attributable visit, else the account default (source_for_opps_and_orders) |
| Account default | an outside-sales auto-assign campaign with an open per-member window, else the customer's own source |
Write-once original (first touch)
original_source_id exists on parties, opportunities, and orders.
| Column | Semantics | Writers |
|---|---|---|
original_source_id |
write-once acquisition evidence, stamped at creation | store_original_source (parties, app/concerns/models/party_cart.rb), stamp_original_source (opps/orders) — then nothing, ever |
source_id |
mutable operational label | app code, CRM users, windowed campaign sync |
The stamping chain is evidence-first: own visit → opportunity's original →
customer's original → resolved source, where Unknown counts as absence
of evidence (skipped unless it is the only candidate). attr_readonly:
reassignment raises. All three columns carry ON DELETE SET NULL FKs, so
deleting a source can never leave dangling references (the pre-2026
source 1151 bug class). History was backfilled with the same chain in
July 2026 (595k records); records with zero evidence stay honestly NULL.
Campaign auto-assignment: per-customer sliding windows
Campaigns with auto_assign_source tag audience members' new CRM
records with the campaign's source — at creation only, bounded per
customer. Nothing retroactive: a campaign never rewrites a record it did
not create.
- The window ends
auto_assign_window_days(default 30) after the
customer's last campaign touch: audience join, email delivery, or
logged campaign activity. Automated sends count, so an
actively-sending drip keeps its window open while a dormant campaign
self-closes. auto_assign_untilis an optional hard cap for event-bound
campaigns (trade show end + follow-up). Unbounded sync is not
expressible.- The creation-default path (
resolve_auto_assign_campaign, via
Campaign#source_assignment_window_open_for?) requires an open window
at the effective date — a dormant 2018 enrollment cannot stamp today's
order. Campaign#synchronize_source, which rewrote the source of records a
customer already had when they joined an audience, was deleted
2026-07-31. Nothing replaced it: influence is a join now (see
Reporting), and 92,346 records were restored from what it overwrote.
Guardrails
- Google-Ads-attributed records lock their source in the UI
(Models::SourceAttributable, Jan 2026). - Visit evidence takes precedence over manually selected sources on
opps; controller prefills are evidence-first so the form default can't
defeat it. - Human source edits are PaperTrail-versioned; the 2026-07-23 backfill
used those versions to reverse bad re-tags (42,442 records restored). Campaign#synchronize_source— which rewrotesource_idon a customer's
opportunities/orders/invoices whenever they joined an audience — was
deleted 2026-07-31. Influence is a join now (see below); 92,346
records were restored from what it had overwritten.
Paid-click campaign resolution
Campaign-sync workers create one child Source per provider campaign and
store the platform ID in campaign_external_id. Landing-page parameters can
therefore resolve directly to that child without maintaining a referral code
on every ad:
| Provider | Dynamic parameter | Resolution |
|---|---|---|
| Microsoft Ads | msad_campaignid={CampaignId} |
provider=microsoft_ads + campaign_external_id |
| OpenAI Ads | oai_campaign_id={campaign_id} |
provider=openai_ads + campaign_external_id |
These exact provider-ID matches run before the generic utm_source / referrer
fallback. For OpenAI Ads, oppref alone still resolves to the ChatGPT Ads
parent; adding the dynamic campaign ID prevents product-feed clicks from being
collapsed there. Tracking::Tracker also retains utm_id and utm_content
so the Visit keeps campaign and ad-group/ad detail even after Source resolution.
The canonical OpenAI parameter template and consent rules live in
TRACKING_SYSTEM.md § Click and campaign attribution.
Trade shows: lead-in → show → follow-up
Sources under the Trade Show root are events, and events attribute
across the whole commercial arc of a show rather than a single instant:
(starts_on - pre_attribution_window_days) .. (ends_on + attribution_window_days)
30 default 90 default
Both sides are per-show and either may be 0. A 0-day lead-in gives
the strict "show and after only" rule; 30 is a default, not a floor.
Models::SourceAttributable refuses to put an event source on a record
created outside that span, on all three of Order / Opportunity / Customer.
Both sides earn their place. The lead-in covers ordinary pre-show
commerce — builders register weeks ahead, reps book appointments against a
show they are about to attend, orders get written in the run-up. An order
booked the week before is the same sale as one taken at the booth
(SO720065, created 2026-02-06 against a show that opened 02-17, is exactly
this). The follow-up is where the calls happen after the show.
What the bounds refuse is a show stamped onto business it has nothing to do
with — a 2011 Toronto show on 20k Costco orders.
Two deliberate escapes, both because a guess is worse than a gap:
- Undated shows accept anything. 340 legacy nodes have no dates (173
have nocreated_ateither) and cannot be judged. - Only a change to
source_idis checked, so correcting a show's dates
does not retroactively invalidate the records already on it.
A new node under the root cannot be created dateless.
Reporting
Report::SourcesReport renders a By Category rollup at the top
level (categories are disjoint roots there; drill-down skips the rollup
because subtree rows would double-count). ROI questions: read the
Marketing row. Channel questions: platform_retailer. First-touch /
CAC questions: query original_source_id, not source_id.
Influence — "which campaigns touched the deals we won?" — is answered
by joining campaign_influenced_invoices (or
Marketing::CampaignInfluence), never by rewriting a source. It is
many-to-many on purpose: a deal touched by three campaigns appears under
all three at full value, so summing that column across campaigns
double-counts revenue. Read it against the period's base rate; an
always-on nurturing programme touches half of everything and will top any
influence ranking by construction.
Ad spend and ROAS
AdSpendSyncWorker writes daily per-campaign spend, clicks and
impressions into source_data_points, parented on the campaign's
Source. So return on ad spend is a join rather than a reconciliation:
-- spend
SUM(value) FILTER (WHERE metric_type = 'ad_spend') -- source_data_points
-- revenue the campaign ACQUIRED, via the order's write-once source
SUM(revenue_consolidated) -- invoices ⨝ orders, invoice_type='SO', gl_date
-- ... JOIN orders o ON o.id = i.order_id
-- WHERE o.original_source_id = <campaign source>
Join acquisition, not invoices.source_id. This section used to say
"revenue for the same source_id", which contradicts the rest of this
document: source_id is the mutable operational label, and campaign
tagging rewrote it for years. Measured on the 2026-07 snapshot, ad
campaigns' 90-day revenue read $108k via invoices.source_id against
$137k via orders.original_source_id — a 26% under-credit, with 11 of
97 invoices disagreeing. ROAS off the label answers "what is currently
tagged to this campaign", not "what did this campaign win".
Deleting synchronize_source stopped the largest rewriter, but it does
not make the label safe to read: the history it wrote is still in the
column, and CRM users re-tag source_id by hand every day. Acquisition
is the join either way.
Influence — deals a campaign touched but did not acquire — is a
separate number and never part of ROAS. For paid media it lives in
source_influenced_invoices (the digital-touch arm, keyed on
sources.id); campaign_influenced_invoices is the CRM-campaign
equivalent and does not contain ad campaigns. Neither column sums across
campaigns.
Each row covers one day, stored as the half-open range [day, day+1),
so period filters need an inclusive overlap: period && daterange(from, to, '[]'). Coverage is Google, Microsoft (Bing), OpenAI (ChatGPT) and
Amazon (Sponsored Products/Brands/Display, seller US+CA). Pinterest,
Meta and Amazon vendor/MX profiles are not included — state that
when reporting totals rather than implying the number is all paid spend.
Do / don't
- Do anchor new attribution logic to evidence with timestamps
(visits, joins, deliveries) and bounded windows. - Do create new sources under the right root — category inherits.
- Don't reintroduce unbounded retroactive tagging or write to
original_source_idafter creation (it raises). - Don't hand-tag partner-account records with marketing/event
sources — platform volume is structural (platform_retailer). - Don't sum source branches for ROI — segment by category.