# Multi-touch attribution contract: fields, invariants and reporting modes

A multi-touch attribution (MTA) view is reproducible only when its rules are written down and versioned before anyone runs a query. Two analysts running "linear attribution" on the same data with unwritten rules will publish different numbers, and neither can explain the difference later.

Use `assets/mta-contract-template.yaml`. Building the tables and dbt tests is the multi-touch-attribution-kit skill; this file defines what the contract must say and how to prove a view obeys it. If that skill is not installed, write outcomes, touches and credit as CTEs inside one read-only SELECT and run the invariants below against those CTEs. Never create tables or views to do it; a CREATE, INSERT or MERGE is drafted for approval in a schema the user names.

## Contents

- Contract fields and the decision each one forces
- Touchpoint set
- Touchpoint source checks
- Invariants with example SQL
- Coverage reporting
- Restate or snapshot
- Versioning

## Contract fields and the decision each one forces

- `model_version`: a name that changes whenever any rule changes, for example `new_customer_linear_v2`.
- `decision`: the sentence from the attribution brief. A contract with no decision gets reused for questions it was not built for.
- `outcome`: one event at one grain, for example `first_paid_order_per_customer` or `opportunity_created`. Renewals, expansions and repeat orders get separate views.
- `outcome_ledger_query`: the ledger query from `metrics.md` with the same period and filters. The outcome table must reconcile with it before credit is computed. An outcome table built from GA4 purchase events, or through an inner join to sessions that drops outcomes with no touch, passes every other invariant while coverage looks higher than it is.
- `unit` and `dedupe_rule`: what makes two outcome rows the same outcome.
- `time_basis` and `reporting_timezone`: payment time, opportunity create date or close date; one timezone.
- `lookback_days`: counted back from the outcome. Set it at or above the 90th percentile of observed first-touch-to-outcome lag; record the lag numbers and date range used. Compute that lag on all available touch history with no lookback cap; a lag computed inside an existing windowed view can never exceed that window. Measured back from outcomes, use only outcomes dated at least the longest candidate lookback after the history start, so long paths can appear. Measured forward from first touches, use only first-touch cohorts older than the longest candidate lookback, so slow converters have had time to convert. If more than 10% of converters' first observed touch falls on the history start date, mark the lag itself as truncated.
- `eligible_touchpoints`: the list in the next section.
- `collapse_rule`: how repeated touches reduce (for example one touch per channel per session).
- `touch_ordering`: the timestamp precision of each touch source, a tie rule (for example source priority, then `touch_id`), and how date-only touches are placed (start of day in `reporting_timezone`, or excluded from position and decay rules). CRM touches such as campaign member response dates, event attendance and imported offline touches are often date-only, so they tie with each other and with timestamped web sessions. Without a stable tie rule, first observed, last non-direct and position based views give different credit on each run, and time decay credit depends on the time given to date-only touches.
- `rounding_rule`: store unrounded weights and values and round only for display. If a displayed table must sum exactly, assign the rounding residual to a stated touch (for example the last eligible touch). Per-touch values rounded to cents can drift past the 0.01 tolerance on long paths.
- `identity_rule` and `allowed_identity_link_types`: which links between anonymous IDs, users, contacts and accounts are allowed, as a list of link types the credit table records per touch. Probabilistic matches are either excluded or flagged.
- `credit_rule`: the model, plus per interaction or per channel, short-path rules, half-life, and direct handling.
- `no_eligible_touchpoint`: always `unattributed`, kept as a row.
- `value_basis`: for example net of discounts and refunds as of a cutoff, before tax and shipping. For B2B, opportunity amount as of a dated snapshot.
- `currency_handling`: separate totals by currency, or conversion at a named rate and date.
- `reporting_mode`: restate or snapshot (below).
- `owner` and `approved_on`.

## Touchpoint set

Write the list explicitly. A typical web set:

- Paid clicks with a click ID or tagged landing URL (search, social, display, video, affiliates).
- Organic search, organic social, referral and email clicks from tagged or classified sessions.
- Optional and labelled: engaged video views, view-through impressions from platforms that share them, logged sales activities, event attendance.

Rules:

- Touches after the outcome are never eligible.
- Touches from internal and test traffic are excluded by a flag set at collection time.
- Impressions and email opens are either excluded or kept in a separate view; they inflate the touch count of the channels that log them.
- For B2B, sales activities count only when associated with both the contact and the opportunity.
- Direct is either excluded or included, never decided per report.

## Touchpoint source checks

The invariants below check arithmetic, not whether each touch has the right source. These defects pass every invariant and still make the view wrong. Run each check before computing credit and attach the result.

- GA4 BigQuery export source fields. The `traffic_source` record describes the source that first acquired the user, not the session or the event. Build touches from `collected_traffic_source` (event level, including `gclid`) or `session_traffic_source_last_click` (session level). Never use `traffic_source.*` for touches. Field names checked against Google's export schema page on 2026-10-18; verify them live before writing SQL. Check: take users with two or more sessions whose `session_traffic_source_last_click` values differ, and count those whose every touch equals their first-acquisition source and medium. Pass: under 5% of those users. Report how many users were tested; users with one session prove nothing either way.
- Referral exclusions. Payment providers, identity providers and the site's own subdomains appear as referrals just before purchase and become the last non-direct touch. Keep an exclusion list in the contract (`referral_exclusions`). Check: list the top referral domains on the last eligible touch before each outcome; any payment, login or own domain in the top ten is a defect.
- Internal UTMs. UTM tags on the site's own banners and links overwrite the campaign source and create fake campaign touches. Check: touches whose `utm_source` is the site's own domain, or whose campaign changes mid-session right after a page view on the same site. Expect zero; exclude them and ask for the tags to be removed from internal links.
- Click ID precedence. A paid click that carries a click ID (`gclid`, `wbraid`, `gbraid` for Google, `fbclid` for Meta, `li_fat_id` for LinkedIn, `msclkid` for Microsoft Advertising, `ttclid` for TikTok, plus any platform in `stack.md`) but no UTM is classed as direct or organic by most channel rules. A click ID outranks a missing UTM: class the touch to the click ID's platform as paid. Check: count touches with a click ID classed as direct or organic; expect zero.
- Email security scanner clicks. Mail security tools open and click links before the recipient does, which inflates email touches, most of all in B2B. Check: email clicks within 60 seconds of send, or several links in one email clicked by one recipient within the same second. Exclude them, and report the share of email clicks removed.
- Native lead forms. LinkedIn Lead Gen Forms and Meta Instant Forms create contacts with no web session, so the lead lands in unattributed or offline sources. Check: contacts whose record source is a lead form integration and who have no session before the outcome. Report them as their own channel line (for example "LinkedIn lead form"), not as unattributed.
- In-app browsers. Social apps open ad links in their own browser; the purchase often happens later in the default browser with a different cookie, so the session that carried the click ID is not joined to the order. Check: the share of paid social landing sessions whose user agent marks an in-app browser, beside paid social's share of first observed touches on converters. A high in-app share with a much lower touch share marks the split. Report it as a coverage limit, not a channel result.
- Ties. Check: run the tie query below on the eligible touches before credit. If tied first or last touches carry more than 5% of outcome value, rerun the view with the alternate tie order as a sensitivity check and report both.

## Invariants with example SQL

Illustrative SQL for three tables (or CTEs), written to run on BigQuery and Snowflake without dialect functions. `attribution_outcomes` has one row per outcome with `value`, `currency`, `outcome_at` and `lookback_start_at`. `attribution_touches` has one row per eligible touch before credit, with `outcome_id`, `touch_id` and `touch_at`. `attribution_credit` has one row per credited touch with `weight`, `credited_value` and `identity_link_type`, plus one row with `channel = 'unattributed'`, `weight = 1` and `credited_value` equal to the outcome value for each outcome without an eligible touch. Credit rows carry no currency of their own; they take it from their outcome by join, so the two cannot drift. Under SQL's three-valued logic a comparison with NULL is neither true nor false, so each check below tests NULLs explicitly. Adapt names to the project; run read-only.

The outcome table reconciles with the ledger by count and value per currency. Run it first, before any credit is computed. Replace the inner query with the ledger query from `metrics.md`, with the same period and filters (expect zero rows):

```sql
WITH ledger AS (
  SELECT currency, COUNT(*) AS n, SUM(value) AS v
  FROM (
    SELECT outcome_id, currency, value FROM ledger_outcomes  -- the metrics.md ledger query
  ) l
  GROUP BY currency
), outcomes AS (
  SELECT currency, COUNT(*) AS n, SUM(value) AS v
  FROM attribution_outcomes
  WHERE model_version = 'new_customer_linear_v2'
  GROUP BY currency
)
SELECT COALESCE(l.currency, o.currency) AS currency,
       l.n AS ledger_outcomes, o.n AS model_outcomes,
       l.v AS ledger_value, o.v AS model_value
FROM ledger l
FULL OUTER JOIN outcomes o ON o.currency = l.currency
WHERE COALESCE(l.n, 0) <> COALESCE(o.n, 0)
   OR ABS(COALESCE(l.v, 0) - COALESCE(o.v, 0)) > 0.01;
```

Each outcome appears once (expect zero rows):

```sql
SELECT outcome_id, COUNT(*) AS n
FROM attribution_outcomes
WHERE model_version = 'new_customer_linear_v2'
GROUP BY outcome_id
HAVING COUNT(*) > 1;
```

Every outcome has credit rows, including unattributed ones (expect zero rows):

```sql
SELECT o.outcome_id
FROM attribution_outcomes o
LEFT JOIN attribution_credit c
  ON c.outcome_id = o.outcome_id AND c.model_version = o.model_version
WHERE o.model_version = 'new_customer_linear_v2'
  AND c.outcome_id IS NULL;
```

Weights sum to 1 per outcome (expect zero rows):

```sql
SELECT outcome_id, SUM(weight) AS total_weight
FROM attribution_credit
WHERE model_version = 'new_customer_linear_v2'
GROUP BY outcome_id
HAVING COUNT(weight) < COUNT(*)
    OR ABS(SUM(weight) - 1) > 0.000001;
```

Credited touches sit before the outcome and inside the lookback (expect zero rows):

```sql
SELECT c.outcome_id, c.touch_id, c.touch_at, o.outcome_at
FROM attribution_credit c
JOIN attribution_outcomes o
  ON o.outcome_id = c.outcome_id AND o.model_version = c.model_version
WHERE c.model_version = 'new_customer_linear_v2'
  AND c.channel <> 'unattributed'
  AND (c.touch_at IS NULL OR o.outcome_at IS NULL OR o.lookback_start_at IS NULL
       OR c.touch_at >= o.outcome_at OR c.touch_at < o.lookback_start_at);
```

Credit per outcome equals the outcome's value, which catches one deal amount copied onto several contact paths (expect zero rows):

```sql
SELECT c.outcome_id, o.value, SUM(c.credited_value) AS credited
FROM attribution_credit c
JOIN attribution_outcomes o
  ON o.outcome_id = c.outcome_id AND o.model_version = c.model_version
WHERE c.model_version = 'new_customer_linear_v2'
GROUP BY c.outcome_id, o.value
HAVING o.value IS NULL
    OR COUNT(c.credited_value) < COUNT(*)
    OR ABS(SUM(c.credited_value) - o.value) > 0.01;
```

Totals reconcile per currency (expect zero rows):

```sql
WITH ov AS (
  SELECT currency, SUM(value) AS v
  FROM attribution_outcomes
  WHERE model_version = 'new_customer_linear_v2'
  GROUP BY currency
), cv AS (
  SELECT o.currency, SUM(c.credited_value) AS v
  FROM attribution_credit c
  JOIN attribution_outcomes o
    ON o.outcome_id = c.outcome_id AND o.model_version = c.model_version
  WHERE c.model_version = 'new_customer_linear_v2'
  GROUP BY o.currency
)
SELECT COALESCE(ov.currency, cv.currency) AS currency, ov.v AS outcome_value, cv.v AS credited_value
FROM ov
FULL OUTER JOIN cv ON cv.currency = ov.currency
WHERE ABS(COALESCE(ov.v, 0) - COALESCE(cv.v, 0)) > 0.01;
```

Every credited touch uses an identity link the contract allows (expect zero rows). Replace the list with the contract's `allowed_identity_link_types`:

```sql
SELECT c.outcome_id, c.touch_id, c.identity_link_type
FROM attribution_credit c
WHERE c.model_version = 'new_customer_linear_v2'
  AND c.channel <> 'unattributed'
  AND (c.identity_link_type IS NULL
       OR c.identity_link_type NOT IN ('same_device_cookie', 'logged_in_user_id', 'verified_crm_contact'));
```

Tied first or last eligible touches, and the value that depends on the tie rule. Run it on the eligible touches before credit, because first and last touch views keep only one credited row:

```sql
WITH t AS (
  SELECT outcome_id, touch_at,
         MIN(touch_at) OVER (PARTITION BY outcome_id) AS first_at,
         MAX(touch_at) OVER (PARTITION BY outcome_id) AS last_at
  FROM attribution_touches
  WHERE model_version = 'new_customer_linear_v2'
), tied AS (
  SELECT outcome_id
  FROM t
  GROUP BY outcome_id
  HAVING SUM(CASE WHEN touch_at = first_at THEN 1 ELSE 0 END) > 1
      OR SUM(CASE WHEN touch_at = last_at THEN 1 ELSE 0 END) > 1
)
SELECT o.currency,
       COUNT(tied.outcome_id) AS tied_outcomes,
       SUM(CASE WHEN tied.outcome_id IS NOT NULL THEN o.value ELSE 0 END) AS tied_value,
       SUM(o.value) AS total_value
FROM attribution_outcomes o
LEFT JOIN tied ON tied.outcome_id = o.outcome_id
WHERE o.model_version = 'new_customer_linear_v2'
GROUP BY o.currency;
```

Ties change nothing in a linear per-interaction or time decay view (tied touches get equal weight); they move credit in first observed, last non-direct and position based views.

## Coverage reporting

Report coverage before any channel share, on the outcome table after it has reconciled with the ledger:

```sql
SELECT
  o.currency,
  COUNT(*) AS outcomes,
  SUM(CASE WHEN a.outcome_id IS NOT NULL THEN 1 ELSE 0 END) AS outcomes_with_touch,
  SUM(o.value) AS value_total,
  SUM(CASE WHEN a.outcome_id IS NOT NULL THEN o.value ELSE 0 END) AS value_with_touch
FROM attribution_outcomes o
LEFT JOIN (
  SELECT DISTINCT outcome_id
  FROM attribution_credit
  WHERE model_version = 'new_customer_linear_v2' AND channel <> 'unattributed'
) a ON a.outcome_id = o.outcome_id
WHERE o.model_version = 'new_customer_linear_v2'
GROUP BY o.currency;
```

- State coverage as count share and value share. If 70 of 100 new customers have an observed path, coverage is 70%; the other 30 stay in acquisition cost and business reporting.
- If either share is below 50%, the headline is the coverage gap and how to close it (click ID capture, identity, consent, CRM source fields), not the channel mix.

## Restate or snapshot

- Restate: rerun history under the current contract. Trends are comparable; published numbers change.
- Snapshot: keep each period's result under the rules used at the time. Published numbers stay fixed; trends cross rule changes.
- Pick one per view and print it on the report. Mixing them silently makes trend lines meaningless.
- Platform equivalents: a GA4 reporting model change restates history; GA4 lookback changes, Google Ads window changes and LinkedIn window changes apply going forward only. A Google Ads model change shows from the switch date in standard columns, while the "(current model)" columns show history under the new model.

## Versioning

- Any change to outcome, touchpoints, lookback, identity, credit rule or value basis is a new `model_version`.
- Keep first observed and last non-direct views alongside any multi-touch view as fixed reference points. They are alternative views of the same outcomes; never add their credit together.
- Record each version in `martech-context/decisions.md` with the date, the reason and the expected effect on reported numbers.
