# Canonical Attribution Dataset

**Status:** Approved (Frozen)
**Applies from:** Plugin v1.11.0
**Owner:** Shulman UTM Attribution for Elementor

> This document is the permanent architectural contract for the Attribution Engine.
> Any change to the Canonical Attribution Dataset must be reflected in this document before implementation.

## Purpose

This document defines **what** the Attribution Engine produces and **what should be persisted**
for every conversion type. It is the single source of truth for attribution-related development
in this plugin.

This document does **not** define *how* attribution is calculated. Source resolution, first/last-touch
logic, normalization rules, and direct/untracked classification live entirely inside a single,
dedicated Attribution Engine component. No conversion type, module, or integration may implement its
own attribution decision logic — they consume the Engine's output and persist a subset of it.

This document is an architectural specification. Outside of the [Canonical Field Mapping](#canonical-field-mapping)
section — which exists specifically as a technical reference and is called out as the deliberate
exception — it describes the contract, not the current code. It should remain valid even if the
internal implementation is refactored, as long as the contract itself doesn't change.

---

## Core Principles

1. **One Canonical Attribution Dataset.** A single agreed field list, shared meaning, shared name, across every conversion type.
2. **One Attribution Engine.** All attribution decisions (source resolution, normalization, first/last-touch, direct/untracked classification) happen in exactly one place.
3. **One Source of Truth.** Every conversion type reads from the Engine's output — no conversion type re-derives or re-interprets attribution independently.
4. **Prefer additive changes.** New fields and new conversion-type coverage are welcome. Changes that alter existing meaning are not.
5. **Never silently change the meaning of an existing field.**
6. **Never rename an existing field.**
7. **Never remove a field.** Mark it Deprecated / Reserved instead — see [Deprecated / Reserved](#deprecated--reserved).
8. **Maintain full backward compatibility.** A record written before a field existed is valid; it simply lacks that field. Consumers must treat a missing/empty value as "not available at write time," not as corrupt data.
9. **Every future integration uses this same dataset.** A new conversion type is expected to map onto these fields, not invent parallel ones.

---

## Terminology

These terms are used consistently throughout this document and should be used the same way in all
future attribution-related documentation and development.

| Term | Meaning |
|---|---|
| **Resolved** | A value has been computed and decided by the Attribution Engine — the single authoritative decision-maker — from available signals. "Resolved" describes something the Engine has produced, independent of whether it's stored anywhere yet. |
| **Normalized** | A resolved value has passed through the Engine's shared normalization rules (for example, consolidating aliases so the same real-world channel is represented the same way regardless of which raw signal produced it). Not every field has a normalization step — a field can be Resolved without being subject to normalization. |
| **Persisted** | A value has been written to durable storage for a specific conversion type, so it survives beyond the request that produced it. |
| **Read** | A persisted value can be retrieved from storage by some code path. Read is a precondition for Used, Displayed, or Exported — but satisfying Read alone does not imply any of those are actually happening. |
| **Used** | A value that has been Read is actually consumed by some feature — feeding a calculation, a filter, a report aggregation, or another decision — rather than simply being fetched and discarded. |
| **Displayed** | A value is rendered somewhere a person using the plugin's admin screens can see it. |
| **Exported** | A value is included in a CSV export, or equivalent, that a site owner can take out of the system. |

A field can legitimately be Resolved and Persisted without being Used or Displayed — that is not
automatically a defect, but it should be a deliberate, documented state (see
[Implementation Expectations](#implementation-expectations)), not a silent gap.

---

## Attribution Lifecycle

This shows where data flows through the system, and — critically — the one point where an actual
attribution *decision* is made. Everything before that point is raw signal; everything after it is
the resolved result flowing onward.

```
Visitor
  |
  v
Landing URL                    -- raw signal
  |
  v
Tracking layer (client-side)   -- raw signal capture
  |
  v
Cookies / Local Storage        -- raw signal storage (not yet a decision)
  |
  v
+-------------------------------+
|   ATTRIBUTION ENGINE           |  <-- the only place a decision is made
|   (resolve -> normalize ->     |      (source resolution, first/last-touch,
|    classify)                   |       normalization, direct/untracked)
+-------------------------------+
  |
  v
Canonical Attribution Dataset  -- the resolved, normalized output
  |
  v
Persistence (per conversion type) -- data flow, no new decisions
  |
  v
Admin UI                       -- data flow
  |
  v
Reports                        -- data flow
  |
  v
CSV Export                     -- data flow
```

**The rule this diagram exists to enforce:** everything above the Attribution Engine is raw material,
and everything below it is the same resolved dataset simply moving between layers. If a future
integration, report, or export ever needs to *decide* something about a source that isn't already in
the Canonical Attribution Dataset, that decision belongs inside the Attribution Engine box — not
downstream of it.

---

## How to read the field tables below

- Y — implemented and working as of v1.11.0
- N — not implemented for this conversion type
- Partial — partially implemented / conditional (see accompanying note)
- **N/R** — Not yet Resolved by the Attribution Engine. The field cannot be Persisted, Read, Used,
  Displayed, or Exported anywhere until this is addressed — this is an Engine gap, not a persistence
  gap, and closing it requires its own scoping/design step like any other Engine change.

---

## Required Attribution Fields

Persisted for every conversion type wherever technically possible.

### UTM

| Field | Resolved by Engine | Elementor Leads | WooCommerce Orders | WC Analytics Events |
|---|---|---|---|---|
| `utm_source` | Y | Y | Y | Y |
| `utm_medium` | Y | Y | Y | Y |
| `utm_campaign` | Y | Y | Y | Y |
| `utm_content` | Y | Y | Y | Y |
| `utm_term` | Y | Y | Y | Y |
| `utm_placement` | **N/R** | N | N | N |

> `utm_placement` is part of the Required Canonical Attribution Dataset. It is currently marked
> **N/R** because the Attribution Engine does not yet resolve this value — it is not captured from
> the landing URL and not produced anywhere in the Engine's output. Closing this requires Engine work
> (capturing the parameter and adding a new resolved output). Once Engine support exists, every
> conversion type is expected to persist it as part of the Required dataset — no separate approval
> is needed at that point to treat it as Required, since that classification is already settled here.

### First Touch

| Field | Resolved by Engine | Elementor Leads | WooCommerce Orders | WC Analytics Events |
|---|---|---|---|---|
| `first_touch_source` | Y | Y | Y | Y |
| `first_touch_medium` | Y | Y | Y | Y |
| `first_touch_campaign` | Y | Y | Y | Y |
| `first_touch_content` | Y | Y | Y | Y |
| `first_touch_term` | Y | Y | Y | Y |

### Last Touch

| Field | Resolved by Engine | Elementor Leads | WooCommerce Orders | WC Analytics Events |
|---|---|---|---|---|
| `last_touch_source` | Y | Y | Y | Y |
| `last_touch_medium` | **N/R** | N | N | N |
| `last_touch_campaign` | **N/R** | N | N | N |
| `last_touch_content` | **N/R** | N | N | N |

> Today the Engine only models `last_touch_source` as a value distinct from `utm_source` — it exists
> specifically to preserve "a real last touch occurred, even though `utm_source` resolved to
> `direct`/`untracked`." It does not currently produce independently-resolved
> `last_touch_medium`/`campaign`/`content` values distinct from the primary UTM resolution. Making
> these genuinely independent fields (able to diverge from `utm_medium`/`campaign`/`content`) is
> Engine work and needs its own design pass.

### Click IDs

| Field | Resolved by Engine | Elementor Leads | WooCommerce Orders | WC Analytics Events |
|---|---|---|---|---|
| `gclid` | Y | Y | Y | Y |
| `fbclid` | Y | Y | Y | Y |

`fbp` / `fbc` are **not** part of this contract. They were considered previously and explicitly not
adopted: the Engine has no capture mechanism for them, and introducing one raises a separate
consent/data-source design question. Wanting them in future is a new Engine-capability proposal, not
an addition to this table.

### Classification

| Field | Resolved by Engine | Elementor Leads | WooCommerce Orders | WC Analytics Events |
|---|---|---|---|---|
| `confidence` | Y | Y | Y | Y |

The direct/untracked determination is not a separate field — it is expressed through the *value* of
`utm_source` (`direct` / `untracked`) combined with `confidence`. This is why the Deprecated/Reserved
`status` field is redundant with this pair — see [Deprecated / Reserved](#deprecated--reserved).

### Journey

| Field | Resolved by Engine | Elementor Leads | WooCommerce Orders | WC Analytics Events |
|---|---|---|---|---|
| `session_count` | Y | Y | Y | Y |
| `landing_page` | Y | Y | Y | Y |

`conversion_page` and `referrer` have moved to [Recommended](#recommended-attribution-fields) — see
the reasoning below.

---

## Recommended Attribution Fields

Provide future analytical value; persisted where practical, but not required for the current
attribution model.

| Field | Resolved by Engine | Elementor Leads | WooCommerce Orders | WC Analytics Events |
|---|---|---|---|---|
| `conversion_page` | Y | Y | Y | Y |
| `referrer` | Y | Y | Y | Y |
| `visitor_id` | N (not an Engine concept) | N | N | Y |
| `session_id` | N (not an Engine concept) | N | N | Y |
| `user_id` | N (native WordPress/WooCommerce concept, not an Engine output) | N | Partial — available natively through WooCommerce's own order/customer data, not persisted separately as an attribution field | Y |

### Why `conversion_page` and `referrer` moved from Required to Recommended

Both fields are already fully implemented across every conversion type as of v1.11.0, so this
reclassification doesn't remove anything already relied upon — it's a statement about what the
contract *requires going forward*, not a rollback.

**`referrer`** is fundamentally an *input* to the Engine's decision, not a resolved output in its own
right. Its main job is feeding source resolution (e.g. matching known referring domains) and helping
explain, after the fact, why something resolved to `direct` or `untracked`. Once `utm_source`,
`utm_medium`, `first_touch_*`, and `last_touch_source` are resolved, `referrer`'s incremental
reporting value is small — nobody builds a report "grouped by referrer" the way they build one
"grouped by campaign." Its primary audience is debugging/support, not attribution analytics. That
profile fits Recommended better than Required.

**`conversion_page`**'s value turns out to be structurally uneven across conversion types rather than
uniformly useful. For Elementor Leads, where the same form can be embedded across many different
pages, knowing which page a submission happened on is genuinely informative. For WooCommerce Orders,
the conversion page is almost always the same checkout URL for every order on a given store — its
variance, and therefore its analytical value, is close to zero for that conversion type. A field whose
usefulness depends heavily on which conversion type is asking doesn't fit "required wherever
technically possible" as cleanly as a field with consistent value everywhere — which is exactly the
description Recommended is meant for.

By contrast, `landing_page` **remains Required**: it's the page-level companion to
`first_touch_source` (which campaign, landing on which specific page, began the journey), it's
uniformly meaningful regardless of conversion type, and it's already an established reporting
dimension for page-level campaign performance. `utm_content`/`utm_term` also remain Required despite
being fine-grained and sometimes empty, because — unlike `referrer` — they are genuine,
independently-queried marketing dimensions (creative- and keyword-level performance) with an
identified business consumer, satisfying the business-purpose bar in
[Implementation Expectations](#implementation-expectations).

---

## Internal Fields

Exist internally; not part of the public attribution contract.

| Field | Notes |
|---|---|
| `device` | Resolved locally within the WooCommerce Analytics component via user-agent inspection — not produced by the Attribution Engine. Currently captured but not surfaced in any report. |
| `browser` | Same as above. |
| `os` | Same as above. |

Internal fields may be added, changed, or removed without going through the backward-compatibility
rules that govern Required/Recommended/Deprecated fields, since they are not part of the public
contract. If a reporting need ever promotes one of these to Recommended or Required, that promotion
follows the normal additive process and must satisfy the business-purpose requirement below — it is
not a meaning change to the field itself.

---

## Deprecated / Reserved

Not removed. Reserved for backward compatibility. New code must not read or write these as part of
implementing new features.

| Field | Why deprecated | Current state |
|---|---|---|
| `utm_id` | Never resolved by the Engine. Duplicates `utm_campaign` for the overwhelming majority of real-world use. | Reserved storage slot exists on Elementor Leads' first-touch record family and on WC Analytics Events; always empty. No equivalent reserved slot exists on WooCommerce Orders. |
| `country` | Reserved storage exists on WC Analytics Events but is never populated by any code path. | Present, always empty. |
| `status` (tracked/untracked) | Fully redundant with `confidence === 'untracked'` (equivalently `utm_source === 'untracked'`). | Present on WooCommerce Orders only; never generalized to other conversion types. |

---

## Conversion-specific data

This document intentionally does **not** define conversion-specific fields. Each conversion type may
have its own data model for the parts of a record that aren't attribution:

**Elementor Leads** — e.g. `name`, `phone`, `email`, `form_name`, `form_id`
**WooCommerce Orders** — e.g. `order_id`, `customer_name`, `order_total`, `payment_method`

These are not part of the Canonical Attribution Dataset and are not subject to the backward-compatibility
rules above (though each conversion type's own storage naturally has its own compatibility expectations).

---

## Custom Fields

The plugin must always support custom attribution fields defined by the site owner, in addition to the
Canonical Attribution Dataset above.

Rules:

- Site owners can expose additional fields using Elementor Field IDs (or the equivalent identifier
  concept in future integrations).
- **Default attribution fields remain unchanged.** A custom field is additive; it cannot alter the
  Required/Recommended/Internal/Deprecated fields defined in this document.
- **Custom fields must never overwrite built-in fields.** If a site owner defines a custom field using
  the name of a canonical field (e.g. `utm_source`), the canonical field wins — the custom definition
  is simply ignored for that name, not merged or blended.
- **Unknown field IDs return an empty value** rather than an error, warning, or fallback guess. A
  missing custom field is a normal, expected state, not a failure.
- Once defined, a custom field should participate wherever technically appropriate, the same way a
  canonical field does: persistence, admin tables, CSV exports, reports.
- **The implementation should remain lightweight.** This is a small extensibility mechanism, not a
  new sub-system — it should not require its own storage engine, its own resolution logic, or any
  parallel attribution decision-making. Custom fields are opaque values captured alongside the
  Canonical Attribution Dataset, not new inputs into how attribution itself is calculated.
- **Reuse the existing developer filter/hook architecture** wherever one already exists, rather than
  introducing a new extension mechanism for this purpose.

The goal is extensibility without touching core attribution logic: a site owner can capture and report
on an additional field end-to-end without any Engine change.

---

## Canonical Field Mapping

This section is the one deliberate exception to this document staying implementation-agnostic. It
exists purely so future maintenance doesn't require searching the codebase to answer "where does this
field actually live?" If internal storage mechanics change, only this section needs updating — the
rest of the document remains valid unchanged.

> **Landing Page Sessions (v1.15.0)** is not a fifth conversion type in the sense the rest of this
> document uses the term — it never resolves attribution itself. It is a session-level aggregate
> counter (one row per visitor session, not per pageview) that exists to make the Landing Page
> Optimization Report's Sessions/rate metrics accurate. Every attribution value in its columns below
> is copied as-is from the Engine's already-resolved output (via `build_session_snapshot()` in
> `landing-pages/class-landing-sessions.php`), the same way WC Analytics Events already does. See
> `docs/RELEASE_CHECKLIST.md` and the module's own file header for the outcome-tracking rules
> (created once per session, updated only on lead/add_to_cart/begin_checkout/purchase, never on a
> plain pageview).

| Canonical Field | Elementor Leads (DB column) | WooCommerce Orders (order meta key) | WC Analytics Events (table column) | Landing Page Sessions (table column) |
|---|---|---|---|---|
| `utm_source` | `utm_source` | `_shulmanattrib_utm_source` | `utm_source` | `utm_source` |
| `utm_medium` | `utm_medium` | `_shulmanattrib_utm_medium` | `utm_medium` | `utm_medium` |
| `utm_campaign` | `utm_campaign` | `_shulmanattrib_utm_campaign` | `utm_campaign` | `utm_campaign` |
| `utm_content` | `utm_content` | `_shulmanattrib_utm_content` | `utm_content` | `utm_content` |
| `utm_term` | `utm_term` | `_shulmanattrib_utm_term` | `utm_term` | `utm_term` |
| `utm_placement` | N/R — not implemented | N/R — not implemented | N/R — not implemented | N/R — not implemented |
| `first_touch_source` | `first_touch_source` | `_shulmanattrib_fc_utm_source` | `first_touch_source` | `first_touch_source` |
| `first_touch_medium` | `first_touch_medium` | `_shulmanattrib_fc_utm_medium` | `first_touch_medium` | `first_touch_medium` |
| `first_touch_campaign` | `first_touch_campaign` | `_shulmanattrib_fc_utm_campaign` | `first_touch_campaign` | `first_touch_campaign` |
| `first_touch_content` | `first_touch_content` | `_shulmanattrib_fc_utm_content` | `first_touch_content` | `first_touch_content` |
| `first_touch_term` | `first_touch_term` | `_shulmanattrib_fc_utm_term` | `first_touch_term` | `first_touch_term` |
| `last_touch_source` | `last_touch_source` | `_shulmanattrib_utm_last_touch_source` | `last_touch_source` | `last_touch_source` |
| `last_touch_medium` / `campaign` / `content` | N/R — not implemented | N/R — not implemented | N/R — not implemented | N/R — not implemented |
| `gclid` | `gclid` | `_shulmanattrib_utm_gclid` | `gclid` | `gclid` |
| `fbclid` | `fbclid` | `_shulmanattrib_utm_fbclid` | `fbclid` | `fbclid` |
| `confidence` | `confidence` | `_shulmanattrib_utm_confidence` | `confidence` | `confidence` |
| `session_count` | `session_count` | `_shulmanattrib_utm_session_count` | `session_count` | N/A — this table already models sessions as rows, so a per-row visit-count column would be redundant |
| `landing_page` | `landing_page` | `_shulmanattrib_utm_landing_page` | `landing_page` | `landing_page` |
| `conversion_page` | `conversion_page` | `_shulmanattrib_utm_conversion_page` | `conversion_page` | N/R — a session can carry more than one outcome (e.g. lead + purchase) with different conversion pages; no single value is meaningful at the session grain, consistent with why this field is Recommended rather than Required (see above) |
| `referrer` | `referrer` | `_shulmanattrib_utm_referrer` | `referrer` | not implemented — not needed by the Landing Page Optimization Report; would only ever be near-unique per row at this grain |
| `visitor_id` | not implemented | not implemented | `visitor_id` | `visitor_id` — own dedicated cookie (`shulmanattrib_sess_vid`), independent of WC Analytics Events' `visitor_id`/cookie; see module file header |
| `session_id` | not implemented | not implemented | `session_id` | `session_id` — deterministic, same `md5(visitor_id + 30-min window)` formula as WC Analytics Events, computed independently against this module's own visitor cookie |
| `user_id` | not implemented | not persisted as attribution data (see note above) | `user_id` | not implemented |
| `device` (Internal) | not implemented | not implemented | `device` | not implemented |
| `browser` (Internal) | not implemented | not implemented | `browser` | not implemented |
| `os` (Internal) | not implemented | not implemented | `os` | not implemented |
| `utm_id` (Deprecated) | not implemented | reserved key exists (`_shulmanattrib_utm_id`), never populated | reserved column exists, never populated | not implemented |
| `country` (Deprecated) | not applicable | not applicable | reserved column exists, never populated | not applicable |
| `status` (Deprecated) | not applicable | `_shulmanattrib_status` | not applicable | not applicable |

---

## Release Gates

Minimum requirements before an attribution-related release is considered production-ready. This is
the official release checklist going forward.

- [ ] **Dataset parity** — every Required field in this document is Persisted for every conversion type wherever technically possible; any exception is documented (N/R or an explained structural limitation), not silent.
- [ ] **Normalization parity** — normalization rules produce identical output for identical input regardless of which conversion type triggered them.
- [ ] **Reports parity** — every Required field that has reached general availability is Displayed somewhere an admin can actually see it, not merely Persisted.
- [ ] **CSV parity** — exports for each conversion type include the same Required fields as one another, subject to each conversion type's own reasonable scope.
- [ ] **Plugin Check** — passes WordPress.org's Plugin Check tooling cleanly.
- [ ] **PHPCS** — passes WordPress Coding Standards.
- [ ] **QA** — manually verified against representative attribution scenarios (paid, organic, direct, referral, multi-session, untracked) across all three conversion types.
- [ ] **Upgrade compatibility** — existing installations retain all existing data after upgrading; schema changes are additive only; no site-owner action required.
- [ ] **Fresh install compatibility** — a brand-new install produces the full current schema correctly without depending on any incremental upgrade path having run.

A release that fails any gate is not automatically blocked from shipping by this document alone, but
any exception must be a deliberate, stated decision — not an oversight.

---

## Non-Goals

These are explicitly outside the scope of the current Canonical Attribution Dataset. They may become
real goals in a future version, but nothing in this document should be read as implying support for
them today, and no implementation should silently start moving toward them without a separate
architectural decision first.

- **Visitor Timeline.** A unified, chronological view of everything a specific visitor did across
  sessions and conversion types. Out of scope — it would depend on the cross-conversion-type visitor
  identity work already deferred in the Recommended fields section.
- **Multi-touch Attribution.** Apportioning conversion credit across multiple touchpoints (e.g.
  linear, time-decay, or position-based models). The current architecture is deliberately
  first-touch/last-touch only.
- **AI Attribution.** Any machine-learning-based or probabilistic modeling of attribution — for
  example, inferring a likely source when no deterministic signal exists. The current classification
  system is rules-based only (explicit/inferred/fallback/untracked confidence tiers), not
  probabilistic or model-based.
- **Cross-device identity stitching.** Recognizing the same real-world person across multiple devices
  or browsers. Out of scope — the current visitor/session concepts are single-device, single-browser
  constructs.

---

## Implementation Expectations

Every conversion type should eventually reach the same attribution quality: **Elementor Leads**,
**WooCommerce Orders**, and **WooCommerce Analytics Events**.

For every attribution field, the plugin should always be able to answer the [Terminology](#terminology)
questions:

- Is it **Resolved**?
- Is it **Normalized**?
- Is it **Persisted**?
- Is it **Read**?
- Is it **Used**?
- Is it **Displayed**?
- Is it **Exported**?

No field should be silently discarded unless it is explicitly classified as **Internal** or
**Deprecated** in this document.

**Every new attribution field must have a clear business purpose.** A field is not added to this
dataset because the Engine happens to be able to produce it, or because "more data" is assumed to be
better — it is added because a specific reporting, analysis, or integration need has been identified
for it. This applies to every tier: a field proposed for Required or Recommended status should come
with a stated reason someone would query, report on, or build against it; a field with no identified
consumer belongs in Internal (or should not be added at all) until that need exists.

---

## Amending this document

This document changes the same way the dataset it describes changes: additive by default, no
silent redefinition, no removal.

- Adding a field, or promoting a field between tiers (e.g. Internal → Recommended), is a normal edit.
- Changing what an existing field *means*, or removing a row entirely, is not — deprecate instead.
- Every addition should be traceable to the business purpose required in
  [Implementation Expectations](#implementation-expectations).
- This document should be updated in the same change that implements a field, not after — an
  implementation that adds a persisted field without a corresponding update here is incomplete.
