# Link builder and launch tests

## What gets built where

- Static rows (email, partners, affiliates, organic posts, QR and vanity redirects, assistant links): `scripts/build_links.py` writes `tagged_url` from `landing_url` and the registry values.
- Macro rows (Google Ads, Microsoft Advertising, Meta, LinkedIn, other ad platforms): nothing is built. The `param_template` string is entered in the platform, and the platform resolves it at click time. Review the string instead: each `utm_` key once, macro syntax for that platform, static parts passing the same checks as static rows.

## Running the builder

Standard library only, Python 3. Run it on a copy of the registry:

```bash
python3 scripts/build_links.py \
  --registry martech-context/tracking-plan/utm-registry.csv \
  --pairs martech-context/tracking-plan/utm-allowed-pairs.csv \
  --hosts example.com,www.example.com \
  --out utm-registry-built.csv
```

It refuses to write over the registry, prints how many rows had problems, and exits with status 1 if any built row failed. The registry changes only after the user approves the output.

## What the builder checks

1. Takes rows with `status` `approved` and no `param_template`; every other row is copied with a `skipped` result.
2. Validates each row:
   - Required: `utm_source`, `utm_medium`, `utm_campaign`, `utm_id`.
   - The source/medium pair is in the allowed pairs file. An `ambiguous` pair needs a `test_result` starting with `pass`. When the pair lists `content_slots`, `utm_content` is one of them.
   - `expected_channel` is filled.
   - Owned values match `^[a-z0-9_]+$`; mediums `^[a-z0-9_-]+$`; `utm_campaign` matches `^20[0-9]{2}_(0[1-9]|1[0-2])_[a-z0-9_]+$` and has 60 characters at most; `utm_id` matches `^[A-Za-z0-9_-]+$`.
   - No `{` or `}` (macros belong in platform rows), no `@`, no run of 7 or more digits (phone numbers, personal IDs), no whitespace.
   - `landing_url` is https on one of the hosts passed in `--hosts`. A link to your own site from your own site is internal and is rejected at review, not tagged.
3. Builds:
   - Keeps existing non-UTM query parameters and the `#fragment`; the fragment goes after the query.
   - Drops every existing `utm_*` key and writes the row's keys once, in a fixed order.
   - Percent-encodes values. Never lowercases the URL; only the owned values were lowercased at entry.
4. Checks the result:
   - Encoded length at most 1,000 characters (GA4's `page_location` limit, counted on the encoded URL). Over 800 is flagged, because click IDs and consent parameters are added at click time.
   - No two rows produce the same tagged URL. Two rows with one URL are one link counted twice in the registry.

Existing parameters are decoded and re-encoded, and a bare flag (`?preview`) is rewritten as `preview=`. Signed URLs, magic links and vendor-generated links must not go through the builder; use the vendor's own tagging method or leave them untagged.

The script was desk-checked against the example files but not executed while this skill was written. Run it on a copy first and compare two or three rows with a hand-built URL.

Spreadsheet teams can build the same URL with a formula, but a formula does not check the pairs, keep an existing query string or place the fragment correctly. Run the builder on the sheet export before launch.

## Launch tests

Test what the audience clicks, through every hop.

### Who does what

- The agent writes a test sheet with one entry per registry row: `link_id`, what to click and where (preview link, test send, partner proof, printed proof), the consent state for each run, and empty evidence fields. It reads GA4 the next day through the GA4 MCP server and fills the observed values.
- A person performs the clicks, scans and form submissions. The agent cannot click an ad preview inside a platform, scan a printed code or send a test email.
- Prefer ad previews and share links over clicks on live ads. A live paid click costs money and adds an internal click to platform data; record any live click in `notes`.

### Conditions

- A fresh private window or new browser profile, a network outside the internal traffic filter, no debug mode.
- Click from the real context:
  - Paid ads: the platform's ad preview, click through, copy the final landing URL from the address bar. On Meta and LinkedIn, confirm every macro resolved (no `{{`, no uppercase macro names, no `%7B`).
  - Google Ads: the landing URL carries `gclid` (or `gbraid`, `wbraid`, `gad_source`, `gad_campaignid`) next to any manual tags, after every redirect.
  - Microsoft Advertising: the URL test tool, then a preview click; each `utm_` key once.
  - Email: a test send to an internal address, clicked from the inbox so the ESP's click tracking runs.
  - Partners and affiliates: the partner's live link or proof; the network's redirect keeps the UTMs and its own click ID.
  - QR: scan the printed proof with a phone camera, not the PDF on screen. The redirect lands on the tagged URL.
  - Assistant links: click from inside the assistant surface where the link is published.

### Consent runs and expected results

- Consent accepted on the landing page. Capture the final URL after every redirect and the first GA4 request after acceptance. Pass: the request's page location carries each `utm_` key once with the registry value and no literal macro. Next day, the observed channel equals `expected_channel`.
- Consent rejected. Check the network request only. Under basic consent mode no GA4 request is sent; under advanced consent mode the Google tag sends cookieless requests with analytics storage marked denied. Record which one the property uses. This run is not a failure and has no next-day check, because a single cookieless session does not reliably produce a report row.
- Consent accepted on a second page. Land with consent pending, browse to another page, then accept. Record whether the next day's session carries the campaign. If it does not, hand the case to consent-debugging and log the loss and its size in `decisions.md`.

### Conversion and CRM

- Submit the intended form or checkout from the tagged landing page with a test identity the team uses for QA.
- Pass: the CRM record holds `utm_source`, `utm_medium`, `utm_campaign` and `utm_id` equal to the resolved URL. First-touch fields keep the values of an earlier tagged visit by the same test identity; latest-touch fields take the new ones.
- If the CRM stores nothing or overwrites first touch, record it; capture is the job of first-party-click-id-capture (planned). Until it ships, list the gap for the user.

### Evidence per row

Stored where `test_evidence` points:

```text
link_id: lnk_0007
tested_on: 2026-11-02
published_url: https://example.com/go/expo26
final_url: https://example.com/forecasting?utm_source=expo_booth&utm_medium=offline&utm_campaign=2026_11_forecasting_launch&utm_id=cmp_2026_314&utm_content=qr
consent_accepted_first_request: page location carries each utm_ key once
consent_rejected: advanced mode, cookieless request with analytics storage denied
consent_second_page: <campaign present next day: yes or no>
utm_keys_once: yes
observed_next_day: <Session source / medium and channels shown in GA4>
expected_channel: Unassigned (default), Offline (custom)
crm_check: <CRM fields equal the URL: yes or no; first touch kept: yes or no>
live_paid_click: no
result: <pass or fail>
```

Next-day check: in GA4, a report or `run_report` call with Session source / medium, Session default channel group, Session manual campaign ID and the custom channel group (if any), filtered to the test's campaign. A row passes when the observed channel equals `expected_channel`. If GA4 shows the right source but the wrong channel, fix the value; if it shows no row, check data filters, then the redirect chain and consent state (ga4-unassigned-not-set-diagnosis).
