Skip to content

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).

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.

Source is an ltree hierarchy (sources table). Every node carries:

  • categorymarketing, outside_sales, platform_retailer, referral_partner, or NULL (reported as Uncategorized). Root nodes were mapped in migration 20260724055123; 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 where Campaign#generate_source parents them, so the two campaign parents carry the distinction: Campaigns > Email and Campaigns > Mailing are marketing (a newsletter is not a rep making calls), Campaigns > Outside Sales is outside_sales. Correct the parent, not the individual campaign — inheritance does the rest.
  • visibility — auto-generated per-blog-post and per-campaign-email nodes are hidden: 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.

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

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

Section titled “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_until is 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.
  • 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 rewrote source_id on 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.

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

Section titled “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 no created_at either) and cannot be judged.
  • Only a change to source_id is 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.

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.

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 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_id after 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.