Skip to content

How an ad carries attribution, and how it is read back - #247

Merged
nandanrao merged 5 commits into
mainfrom
feature/ad-attribution-ref-mode
Aug 25, 2026
Merged

How an ad carries attribution, and how it is read back#247
nandanrao merged 5 commits into
mainfrom
feature/ad-attribution-ref-mode

Conversation

@nandanrao

Copy link
Copy Markdown
Contributor

Builds planning/ad-attribution-ref-mode-plan.md. Two questions about a
recruitment ad's ref, and they are independent:

Question Field Configured in
Write Does the ref carry the stratum inline, or an opaque token? ref_mode, on a destination Destinations
Read Is the value read the answer, or a token identifying the ad? mapping, on an extraction conf Data Extraction

Neither side validates, gates or reads the other. The one place they meet is a
warning: thins_its_ref_without_reading_the_mapping.

swoosh — a lookup composes with either location

locationReader says where to read; resolveThroughAdTable wraps whichever
reader that names. So a lookup works on either location, which is what the whole
design buys: a respondent recruited by a fly destination brings the token back
in event metadata, one recruited by a web or app destination lands on the
researcher's own page and brings it back as a Typeform or Qualtrics field. Each
conf declares where its own token is, and two lookup confs under one source need
not agree.

adAttributionOutcome now reports one thing at severity error: a token that
resolves to no row. An event carrying no token produces nothing — that is an
expected arrival, not a failure — which retires the organic counter and
tokenLookupKey with it.

adopt — one field says what a ref carries

RefMode = Literal["metadata", "encoded"], and RefModeDestination carries
exactly one field. include_metadata_in_ref said the same thing in a form that
could not express "encoded", so the two needed a validator to stop them
contradicting each other; the "shortcode" mode goes too, since its join key
came from Meta's referral webhook, which reaches ~31% of Messenger ad entrants.

Optional keeps the migration free: a conf that states no mode resolves to the
behaviour it has today, and no stored JSON is rewritten.

Every destination type is a RefModeDestination now, web and app included, so
ad_ref_token and the half-migration guard ask resolved_ref_mode with no type
check. messenger_ref becomes dotted_ref, serving Messenger's two carriers,
multi's Messenger arm and the {ref} a web or app destination interpolates.
A destination with a shortcode encodes to r.<payload>; one without has nothing
to decode its ref and swoosh compares the extracted value to ref_token
verbatim, so it encodes to the bare token.

csv_export renders the mapping two ways from one definition, and the table is
served as JSON alongside the CSV.

dashboard

RefModeField is one control rendered by all five destination forms, labelled
by consequence — the words ref_mode and encoded never reach the screen.
Changing a saved destination's mode warns, because it rewrites every ad in the
study on the next run.

An absent ref_mode is a real state and stays absent: displayedRefMode
reports and never writes back, the default lives only in the two empty-state
constructors, and the forms spread ...data. Destination.test.tsx pins the
scenario — open a destination with no mode, edit its welcome message, save, and
the field is still absent.

The fly and Qualtrics extraction forms become one; all a source still decides is
which response values its payload offers. A fly source with nothing saved starts
with one lookup conf per variable declared in Variables. A new Ad
Attributions
step renders the mapping and downloads it as CSV.

Two things found along the way

  • applyChange pinned aggregate to "first", so editing any field on a
    variable conf demoted it back to the first value it ever saw. Latent before;
    a variable lookup makes it visible. Fixed, with a test.
  • Worth a look before merge: a WhatsApp or multi destination's ref default
    changes. include_metadata_in_ref defaulted False on those two types, so an
    existing conf resolved to a thin form.<shortcode> ref; under the plan's
    single field it resolves to "metadata" and carries the stratum inline. That
    is what §2 specifies, and check_whatsapp_refs_are_deliverable fails closed
    on values fly's entry pattern cannot parse — but it means an existing CTWA
    study's ads get rewritten, and one with unsafe stratum values now fails config
    validation instead of publishing a thin ref. Messenger, Web and App are
    unaffected. Say the word and I will make those two types resolve to
    "encoded" absent a stated mode instead.

Verification

dashboard  npx tsc --noEmit          clean
           CI=true npx craco test    166 passed, 15 suites
           CI=true npx craco build   Compiled successfully
adopt      poetry run pytest . -q    700 passed, 1 skipped
inference  go vet ./...              clean
           go test ./... -p 1        all ok

Documentation is its own commit: documentation/ad-attributions.md for the
mechanism, and each app's README for its own half.

🤖 Generated with Claude Code

nandanrao and others added 5 commits August 24, 2026 19:28
The write side asks one question of a destination -- does its ref carry
the stratum inline or an opaque token -- and the read side asks one
question of an extraction conf: is the value read the answer, or a token
identifying the ad. The two are independent, and configured separately.

Specifies both sides, the dashboard controls for each, the ad
attributions surface, and the removals the tree needs to match it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Location says where to read; mapping says what the value read means. The two
were tangled together: isAdTableLookup required the metadata location,
getRetrieveFunc errored on a lookup anywhere else, and tokenLookupKey read one
conf's key as the whole source's declaration of where the token is.

Composing them instead — locationReader for where, resolveThroughAdTable
wrapping whichever reader that names — is what lets a study attribute
respondents who arrive by different routes. One recruited by a fly destination
brings the token back in event metadata; one recruited by a web or app
destination lands on the researcher's own page and brings it back as a Typeform
or Qualtrics field. Each conf declares where its own token is, and two lookup
confs under one source need not agree.

adAttributionOutcome now reports one thing: a token that resolves to no row.
vlab minted an ad and lost what it meant, so every respondent it recruits is
dropped from stratum counts. It walks the source's lookup confs, asks each
through its own reader, and returns on the first miss, so one event yields at
most one outcome. An event carrying no token produces nothing — that is an
expected arrival, not a failure — which retires the organic counter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A ref either carries the stratum inline or carries a token that resolves to it.
`ref_mode` is that field and the only one: `include_metadata_in_ref` said the
same thing in a form that could not express "encoded", so the two needed a
validator to stop them contradicting each other, and every consumer had to know
which one to read. The mode "shortcode" goes with it — it named a ref that
routes and nothing else, whose join key came from Meta's referral webhook, which
reaches ~31% of Messenger ad entrants.

Optional is what keeps the migration free: a conf that states no mode resolves
to exactly the behaviour it has today, and no stored JSON is rewritten.

Every destination type is a RefModeDestination now, web and app included. What a
ref carries is a property of the ref rather than of the channel carrying it, so
`ad_ref_token` and the half-migration guard ask `resolved_ref_mode` of any
destination with no type check, and `messenger_ref` becomes `dotted_ref` —
serving Messenger's two carriers, multi's Messenger arm, and the `{ref}` a web
or app destination interpolates into its template. A destination with a
shortcode routes through fly, whose decoder recovers both from one string, so
its encoded ref is `r.<payload>`; one without has nothing to decode its ref and
swoosh compares the extracted value to `ref_token` verbatim, so its encoded ref
is the bare token.

The read side loses its two constraints on where a token may be read from:
`ExtractionConf` validates only that the mapping is known, and
`disagreeing_token_keys` — which existed because swoosh read one conf's key as
the whole source's declaration — has nothing left to warn about.

`csv_export` renders the mapping two ways from one definition, so the table the
dashboard shows and the file downloaded seconds later cannot disagree about the
columns, which are a union across rows in first-seen order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two questions about a recruitment ad's ref, and they are independent. What it
carries is `ref_mode`, on a destination; what the value read means is `mapping`,
on an extraction conf. The forms now say so.

`RefModeField` is one control rendered by all five destination forms rather than
a copy in each, so a multi-channel study attributes exactly one way, and it is
labelled by consequence: what a researcher decides is where their stratum data
ends up and what the key is, so the words `ref_mode` and `encoded` never reach
the screen. Changing a saved destination's mode warns, because the ref is part
of the creative and reconciliation compares creatives — a change rewrites every
ad in the study on the next run.

An absent `ref_mode` is a real state: the conf predates the field. Three things
hold it — `displayedRefMode` reports and never writes back, the default lives
only in the two empty-state constructors, and the forms spread `...data` — and
Destination.test.tsx pins the scenario they exist for: open a destination with
no mode, edit its welcome message, save, and the field is still absent.

The fly and Qualtrics extraction forms become one. Their locations were already
identical, and what stayed per-source was the mapping — on the reasoning that
only fly carries a token. It does not hold: a respondent recruited by a web or
app destination lands on the researcher's own page and brings the token back as
a Typeform or Qualtrics field. All that a source still decides is which response
values its payload offers. Changing the location leaves the mapping alone, since
location says nothing about what was read.

A fly source with nothing saved starts with one lookup conf per variable
declared in Variables. The researcher already named those, and the name is what
the ad's frozen row is keyed by — asking again in a different vocabulary is what
produces a silent half-config. A default, not a merge.

The Ad Attributions step renders the mapping and downloads it as CSV from the
rows it is showing, so the page and a file saved from it cannot disagree.

`applyChange` derives `aggregate` from the conf's own location rather than
pinning it to "first", which a `variable` lookup made visible: pinned, editing
any other field demoted a survey answer back to the first value it ever saw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`documentation/ad-attributions.md` describes the mechanism across components;
`adopt/README.md`, `dashboard/README.md` and `inference/README.md` describe
their own halves. Each now describes the system as it stands: one field saying
what a ref carries, a mapping that composes with either location, and the one
outcome swoosh reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 25, 2026

Copy link
Copy Markdown

Deploy Preview for vlab-dashboard ready!

Name Link
🔨 Latest commit 038d475
🔍 Latest deploy log https://app.netlify.com/projects/vlab-dashboard/deploys/6a8ced994a1dea0008c91cba
😎 Deploy Preview https://deploy-preview-247--vlab-dashboard.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@nandanrao
nandanrao merged commit f2e38f0 into main Aug 25, 2026
12 of 16 checks passed
@nandanrao
nandanrao deleted the feature/ad-attribution-ref-mode branch August 25, 2026 01:36
nandanrao added a commit that referenced this pull request Aug 27, 2026
Preserved during worktree cleanup. This is the specification PR #247 was built
from; the implementation on this branch is its sibling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant