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:

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

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

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

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.

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