The Movement Athlete · Attribution & tracking

Closed. The attribution brief is empty — every card verified against the live system, not against the message.

Updated 7 Aug 2026 · CLOSED — 0 open for Nic, 0 P0 · The five cards that were open this morning were all confirmed shipped on 7 Aug 2026, 19:24–19:28 UTC: the Meta Marketing API for the ad repoint, individually re-fetched Stripe subscription ids for the unbillable actives, a re-run of the four-page 50%-off crawl, curl -I on every host and app route, and the served /register/ HTML plus the live quiz bundle for the attribution stamps · 1 item still being watched by us, not by Nic (the server-side PaymentIntent stamp, which needs one post-deploy sale to prove) · 1 decision with Aga

←  All tasks

Nic — that is the whole board cleared, and we checked every one of them ourselves before writing it here. The Meta Variant 2 ads are on the quiz root (read off the Marketing API across 200 ads — zero active creatives still reference /2026-v2-2/); all five unbillable v3 subscriptions read canceled when re-fetched by id; the checkout- prefix is gone from all four 50%-off pages and the old broken path still 404s, which is how we know it was removed rather than redirected around; and quiz-test, monitor and grafana-dev all serve X-Robots-Tag: noindex, nofollow. ✅ UPDATE 10 Aug 2026 — the one open question on this board is now answered. On 7 Aug two of the three attribution writes were confirmed in live code and the third — the checkout_version stamp on the PaymentIntent — could not be measured, because the last sale of that day landed about eight hours before you deployed. Eighteen sales later, the checker has run: your quiz-rail write is CONFIRMED, 6 of 6. Every completed sale through the quiz checkout now carries checkout_version, quiz_variant and the full UTM set (including gclid) on the PaymentIntent itself — and that stamp is what let us tell Aga, on 10 Aug, exactly which sales paid advertising created. The legacy /register/ PaymentIntents are still completely bare (0 of 12), so the "legacy money cannot be split by funnel" problem is unchanged. Nothing on this is being handed back to you. Two things stay as they are on purpose: /2026/ is the Control arm, and /2026-v2-2/ stays live as a rollback with no ads feeding it. Click any row for the detail; nothing here needs another document.

✅ Shipped 3–7 Aug — thank you, and here is the proof we read20

Every row below was verified against the system your server is actually running — not against your message. That is the standard, and you have now passed it twelve times. 🔴 The two headlines: pe= closed the last leg of email-revenue attribution (that KPI was reported DARK on the fleet scoreboard with a wiring deadline of 29 Aug — you cleared it 25 days early), and the SPF fix took our own hello@ mail off a permerror under p=reject, where only a DKIM key was standing between us and rejecting our own email. Two consequences to know before anyone reads a dashboard: phantom trial counts collapse and conversion rates jump — both BY DESIGN (clean baselines start 4 Aug, honest read ~11 Aug), and one instruction we gave you was stale — see the star-rating row.

Open for you0

Nothing. This board is empty — for the first time since it was created.
Every card that was open this morning is now in Shipped above, each with the check we ran against the live system written on it. One thing is still being watched, and it is ours, not yours: the checkout_version stamp on the PaymentIntent is server-side, so it can only be proven by a sale created after your deploy — and there had not been one yet when we checked. We built a checker for it. If it ever comes back negative you will hear from us with the evidence attached; silence means it landed.

📦 Folded — merged into a bigger task1

Nothing to do here — this is the record of a task that was merged, not dropped. Aga's call, 6 Aug: everything you send us about a lead should be one task in one sitting, because it is the same file and the same helper each time. The need is real and confirmed live; the old instruction was wrong about where to put it — see the card.

Parked — listed so nothing is invisible1

Assigned to you and not actionable yet. Do not start them until the prerequisite clears and we tell you.

Closed or moved21

Context — the time split, what changed since 27 Jul, and why you're getting this page

The split

~1.5–2h · Nic
Task 1 — three changes now, one sitting: phantoms · debris+bugs · the email-source field-add. Abandoner capture and provisioning closed 3 Aug — already built.
10m test, then ~45m · Nic
Task 2 — run the ?utm_source=nictest acceptance FIRST; the hand-off already forwards the query string, so the fix is first-touch rehydrate for bare landings
2 min · Aga
Delete the /calisthenics-assessment/ Redirection rule (AGA-54) — the JS fix is already live, this completes it
What changed on 2 Aug: a deep verification pass re-checked every claim on this page against the live systems — read-only Stripe scans (both estates), the GA4 Data API, cache-busted fetches of the shipped quiz bundle, and live probes of /lead, RETURN_URL, the success page and /api/quiz/config. Aga's rulings the same day: the abandonment-email check removed (its substance lives in task 4's acceptance tests) · the password gate closed as done · the WP Engine estate moved to Aga (AGA-53) · the email-source join folded into task 1 · every checkout fix scoped to both new estates. Done for you meanwhile: the YouTube diagnosis (task 7) and the assessment-doorway UTM regression (found, fixed with a guarded snippet, verified). The open revenue-plan checkout tasks (8 · 10 · 11 · 19 · 28 · 29 · 30 · 33) were verified one by one and are folded into this page — task 8 turned out already fixed, the rest are tasks 3–8 here. This page is the single home for Nic's work; the source of truth behind it is the tracked queue, and the two are gate-checked against each other.

3 Aug — a code audit of the LIVE repos: everything above was probed from the outside or read from an Apr-2026 clone. On 3 Aug the actual repos (MovementAthlete-Django, MovementAthlete-React-ProdTemp) were audited, and four asks turned out to be already built by you: the typed-email abandoner stamp (task 1, change 2), buyer provisioning (provision_buyer_from_intent, change 3), the UTM write into Stripe metadata on both estates (change 5's premise), and the entire server-side Meta CAPI webhook (task 5). Three diagnoses were corrected — RETURN_URL is proxy-TLS not a source typo, the success page defect is UI/analytics only because provisioning already refuses bad intents, and addToCartValue:0 is on 1wk_trial not monthly_trial. One new constraint appears twice: removing a page-load mint must not remove the capture riding on it (stampPaywallView on quiz, stamp-abandon-email on v3). Also on 3 Aug: Aga deleted the doorway Redirection rule — the UTM regression is verified closed (guard 19 preserved · 0 dropping) — and took the abandonment-lane rebuild herself (AGA-57) and the magnet/YouTube wiring (AGA-58). Later the same day the on-page checkout port was retired as superseded — the 31 Jul ship replaced its premise; the 14-Aug read decides any successor — and its one live fragment became task 4, the dead-button trace (same file as task 3).

Why you're getting this page — what we're about to build on your work

We're switching on personalised conversion email on top of the v3 data you built — trial-conversion (day-3 value, day-6 "your trial ends tomorrow" with the buyer's actual plan and price) · cart-abandon recovery · payment-recovery notes that name the real plan and coupon. Those machines read Stripe. Tasks 1–2 make what they read true and complete — that's why the detail below is worth your time. Everything on this page was live-verified twice, the second time by an independent adversarial pass instructed to refute it.

Aga's own list lives elsewhere now — her click-by-click steps are generated from the same tracked queue as this page, so the two can no longer disagree: Aga & Nic — the step-by-step list. This page is the home for Nic's tasks; anything assigned to Nic appears here. 🔒 Gate (Aga, 6 Aug 2026): nothing enters this brief without Aga's approval and full live verification — everything currently on it carries both.

The Meta Variant 2 ads — repointed to the quiz root, CLOSED 7 Aug 2026

✅ DONE verified 7 Aug 2026, 19:24–19:28 UTC via the Meta Marketing API, 200 ads scanned 2 ad destination URLs — both now on the root AGA-56

✅ Closed — both ads are on the quiz root, read off the Meta API

Nothing left to do on this card. On 7 Aug 2026, 19:24–19:28 UTC we pulled 200 ads from the Marketing API and resolved every destination on the active creatives. Both Variant 2 ads — 120245799998740728 and 120245799998720728 — now point at https://quiz.themovementathlete.com/, the ROOT, which is what makes them survive the next switch. No active creative references /2026-v2-2/ any more. The four ads still on /2026/ are the Control arm and are deliberate; we are recording that here so a future sweep does not re-open them as a defect.

Everything below this line is the original card, kept so the diagnosis and the reasoning stay on the record.

Recorded for everyone else

Your architecture note is now in our canonical docs, because it explains something that had confused us: Django renders the body itself (apps/mail.pyrender_to_string over activation_email.txt, password_reset_email.txt, quiz_email.html, weekly_progress_mail2.html) and ships it over SMTP — SendGrid only delivers and owns none of the copy. That is why Email Activity shows sends while Dynamic/Marketing templates look empty. We will stop looking for the copy in SendGrid.

✅ 6 Aug 2026 (PM): Aga reviewed this card and SENT THE TASK TO YOU — AGA-56 (her decision-and-dispatch half) is closed, and she ruled REPOINT. 7 Aug: most of it is already done. You shipped the root repoint and moved all Google Ads onto the $97 build — both verified live. One thing is left: the Meta “Variant 2” ads. See the next card.

📝 Your documentation of the three versions — recorded here so we all read the same thing

You sent this on 7 Aug 2026. It is captured verbatim below (left) with what the wire says the same day (right), so nothing depends on memory. Everything matches except one line, flagged in red.

What you documentedVerified live 7 Aug 2026
Root force-redirect changed — was quiz…//2026-v2-2/, now quiz…//2026-quiz-checkout-v1/curl -I: root returns 302 → /2026-quiz-checkout-v1/
1 · Current primary funnel. Landing /2026-quiz-checkout-v1/ · no React paywall, redirects to checkout · checkout app…/payments/quiz-checkout/ · success …/quiz-checkout/success✅ all three 200. Stripe confirms the rail: subscriptions carry checkout_version=quiz_checkout, quiz_variant=quiz_checkout_2026_v1, offer=quiz97
2 · /2026-v2-2/ — “still live (200), not redirected” · in-quiz paywall · pays via /register/monthly_2026/, /register/monthly_2026_promo/promo/, /register/quarterly_2026_promo/promo/ · success /payments/success_2026✅ 200, no redirect — your status line is right. (Your covering note also said “I simply redirect /2026-v2-2/ → v1”; that one is not live, and the status line is the accurate half.) ✅ Stripe agrees on the rail: the only prices selling there are monthly3, monthly2, quarterly2
3 · /2026/ Control · in-quiz paywall · pays via /register/yearly_2026/ · success /payments/success_2026 · served by QLG | ADV+ OFF | MC | Control, kept for its low CPLs✅ 200 · Stripe agrees: the only price selling on that rail is yearly3 · ✅ campaign ACTIVE, both its ads point at /2026/. Your URL map and Stripe's price map agree independently — that is the two-source check on this whole table.
“All Google Ads goes here now” (to v1)✅ Confirmed in GA4: all four Quiz Funnel campaigns land on v1 from wk31. v2-2's Google traffic went to ~0 on 31 Jul.
“Meta Ads QLG | ADV+ ON | US | Variant 2 goes here” (to v1) · and “due to the force redirect, Variant 2 no longer serving /2026-v2-2/🔴 Not yet. Read off the live creatives via the Marketing API on 7 Aug, the campaign is ACTIVE and both of its ads still have https://quiz.themovementathlete.com/2026-v2-2/ as their destination URL (ads 120245799998740728, 120245799998720728). The root redirect cannot catch them — they land one level below the root. GA4 confirms the consequence: 7–14 Meta sessions/day on v2-2 every day since the switch, flat, no decay, and $37.43 spent 1–6 Aug against a funnel whose rail has taken $0 in August.

So: the redirect did its job everywhere it could reach. Ad deep-links are the one place it structurally cannot — which is exactly what this card has been about since 6 Aug.

🥇 Why this outranks everything else on this page

To be precise about what IS working: the $97 page is live, the quiz ROOT is repointed to it (you shipped that), Google Ads is fully moved across, and it is SELLING — 8 paid subscriptions on the quiz-checkout rail since 31 Jul: 7 × $97.00 annual + 1 × $12.48 monthly, $691.48 gross (re-pulled 7 Aug PM; GA4 independently corroborated the first week). Everyone who arrives through the root — YouTube, PMax, direct, homepage — sees the $97 annual. The gap is only the paid deep-links: the ACTIVE Meta ads’ destination URLs (read from the ad creatives via the API, re-verified 7 Aug) are literally quiz.themovementathlete.com/2026/ and …/2026-v2-2/ — one level BELOW the root, so the root redirect never fires for them, and those paths still 200 with their own old bundles. Measured on human landings in the 7 days after the switch: 232 of 540 (43%) still reach a build with no $97 annual — 16% deliberately (the Control arm Aga is keeping) and 27% unintended (v2-2 + v2-3). The ad money sees the old offer; everyone else sees the new one.

📊 Full version-by-version analysis — traffic, sources, funnels, AOV, retention and lead quality across all three funnels, with every command to reproduce it: tma-growth-cockpit-2026.netlify.app/quiz-funnel-versions/ ↗

What each doorway is, and who feeds it — bundle identities verified live 6 Aug

PathWhat it is (from its served bundle)Who lands there (GA4, 14d landing sessions)
/2026/The ORIGINAL 2026 quiz build (bundle index-BqqEpqKj.js). Pays via 6 /register/ URLs · 0 checkouts-v3 refs · no $97 annual.317 — 80% from ONE ACTIVE Meta campaign: “QLG | ADV+ OFF | MC | Control” (ig 149 + fb 95 + threads 10).
/2026-v2-2/The v2-2 quiz build (index-DPkL72Wi.js). Same 6 /register/ URLs · 0 v3 · no $97 annual.691 — direct 219 · google organic 160 · “QLG | ADV+ ON | US | Variant 2” (ACTIVE) fb 87 + ig 22 · homepage 41 · PMax 32 · BWTA 18.
/2026-v2-3/The v2-3 build (index-DBgHnaAZ.js). Older /register/ set · no $97 annual.46 — incl. an ActiveCampaign email: 967 TMA | Welcome Sequence After LEAD MAGNET, E7 still links it (that fix is Aga-side).
/2026-quiz-checkout-v1/The $97 build (index-DFwXN-qe.js) — 12-Month $97 today / $157 struck, pays via the checkouts-v3 rail.3,157 — YouTube organic 1,197 · PMax · Competitors Branded · homepage · the quiz root.

The session counts above are the 6 Aug 14-day snapshot, kept for the record. The current post-switch picture is in the analysis doc linked above.

Where that leaves the old A/B: the Control arm (/2026/) stays live on purpose — Aga is keeping it, and it is the only funnel still selling the trial + 50%-off annual. The Variant 2 arm (/2026-v2-2/) is the one that is running on inertia: still taking paid traffic on the pre-$97 offer months after the test stopped being read. Repointing its two ads ends that, and leaves the page itself intact as the rollback option.

What is left — one campaign, two ads

AGA-56 is RULED and CLOSED (Aga, 6 Aug): repoint. Backport is off the table. Three of the four pieces are shipped; here is the whole board:

PieceStatus 7 AugWho
Root force-redirect → v1DONE — 302 verified liveYou — shipped
All Google Ads onto v1DONE — all four Quiz Funnel campaigns land on v1, GA4-verifiedYou — shipped
/2026/ Control ads stay putBY DESIGN — Aga is keeping this arm (cheap multi-country leads, and it is the only live seller of the trial+50% annual). Not a defect; leave it.No action
“QLG | ADV+ ON | US | Variant 2” ad destinations🔴 OPEN — both ads still read /2026-v2-2/~5 min. In Ads Manager set both ads’ website URL to https://quiz.themovementathlete.com/the ROOT, not the v1 path, so the ads follow every future switch automatically and never need touching again. Keep the existing url_tags as they are. Aga's ruling 7 Aug: this one is yours to decide and do — whether to repoint to the root, and when.

Done when: both ads in 120245799636960728 read the root as their destination, and GA4 Meta sessions on /2026-v2-2/ fall from ~7–14/day to ~0 within 48h. Leave the /2026-v2-2/ page itself serving — Aga wants the rollback option kept open, and once the ads move, its only remaining traffic is organic/direct residue. A 301 on it is a later decision, not this task.

Capture what the quiz already knows — SHIPPED 7 Aug 2026

DONE · verified live 7 Aug by ActiveCampaign census activecampaign_sync.py + the quiz bundle NIC-54

✅ Shipped — and here is exactly what we checked, so you know the standard

Nic — nothing left to do on this card. We did not take it on trust. We ran a live census of the ActiveCampaign API on 7 Aug and read real contacts, not config: all 15 NEW quiz-* tags exist and are being applied (email-captured 8 · questions-done 8 · gave-email 8 · results-seen 4 · reached-checkout 5 · card-started 1 · all-fundamentals-mastered 0, which is correct — nobody is 9/9 yet), and all 10 fields exist and are populating.

One real lead, so you can see it end to end — contact 378032, created 06:43 on 7 Aug: %TMA_QUIZ_PERSONA% = SKILL_SEEKER, %TMA_QUIZ_OBSTACLE% = CONFUSION, %TMA_QUIZ_AGE% = 18_24, %TMA_QUIZ_GENDER% = MALE, %TMA_QUIZ_LIFE_CONTEXT% = FLEXIBLE, %TMA_QUIZ_BACKGROUND% = GYM, %TMA_QUIZ_TRAINING_RECENCY% = CURRENT, %TMA_QUIZ_WEAKEST_FUNDAMENTAL% = Abs, and %TMA_QUIZ_FUTURE_STAKES% = “Never unlocking the skills I know are possible”. That last one is a sentence a human typed about their own life, and until today it died at the end of the quiz.

🔴 And the test that actually mattered: the AC tag total is still 5,845 — exactly the baseline we handed you. You put the values in fields and the state in tags, so no value leaked into a tag name and the 5,623-junk-tag pattern did not recur. A test that only asked “did the code run” would have passed through that bug; this one did not have to.

Re-runnable by anyone, any time: python3 tools/retention/quiz_lead_signal_check.py --sample 2 (read-only; exit 0 = healthy, and it fails loudly if the tag total ever climbs).

One thing we spotted and then closed ourselves — logged so you never inherit it: the paywall-view count briefly ran ahead of the results-screen count, which normally means a screen is failing to stamp. It was not. It was one contact (378018, 00:57) that carries the account tags and reached-checkout but none of the lead-write tags and none of the ten fields — it went through before the lead write was live, since the first complete one lands at 02:47. Every one of the eight leads since is clean and complete. Deploy boundary, not a defect, nothing for you to do.

And a correction to our own spec, not to your code: we told you quiz-email-captured fires "before any answer is submitted." That was wrong about this funnel — the email screen is #32, after the questions (1–31), so email-typed and answers-submitted are genuinely the same moment. The two tags landing in the same second, on 8 of 8 leads, is correct behaviour. We have fixed the spec our end.

The specification below is left in place as the record of what was built — and the tag/field law in it stands permanently for anything that writes to ActiveCampaign next.

🔴 THE LAW THIS SHIPPED UNDER — kept because it binds everything written to ActiveCampaign from here on (historical: it reversed an earlier instruction, and you followed the corrected one)

Do NOT use ActiveCampaignAPIHelper.add_tag_to_quiz_contact() for these tags. The quiz-funnel email master told you to ("same one-line pattern, five places"). That instruction is withdrawn. Here is what it actually does — app/apps/users/helpers.py:90:

self.add_tag_to_contact_by_id(contact_id, f'{tag_name}: {tag_value}')
# the VALUE is concatenated into the tag NAME

That is the machine that built our tag mess. Measured live 6 Aug: ActiveCampaign holds 5,845 tags, and 5,623 of them (96.4%) are one pattern — "Gender: Female; Age: 10-19; Fitness Goal: …". Three pieces of information became 5,623 tags. Each one describes a single person (we sampled: 1.1 contacts per tag, max 2), so none of them can ever be used as a segment.

Passing a timestamp as tag_value would mint one unique tag per contact per stage — five new tags for every lead, forever. It also speaks the retired v1 API. ✅ The correct pattern is already in the file you are editing: activecampaign_sync._apply_tags() applies fixed tag names by id via POST /api/3/contactTags. Copy that.

The rule, so this never happens again

Goes in a…CarriesWhy
TAGstate — did this happen, yes/noFixed set. Segmentable. Never grows.
CUSTOM FIELDdata — the value itselfOne field, many values. Queryable and updatable.

If a tag's name would differ between two contacts, it is a field.

Why this matters — and why we are not putting it in Stripe

The quiz asks people four revealing things: what is standing in their way, what they fear if nothing changes — in their own words, their age, and their gender. It also works out whether they have mastered all nine fundamentals. Today every one of those dies at the end of the quiz. So a recovery email can say "you wanted to build muscle" and nothing more.

🔴 The earlier plan said to route this through Stripe. That was wrong, and here is why

The original task (NIC-45) told you to append these to the quiz→checkout hand-off URL so they would ride into Stripe metadata. Two reasons that fails:

1. Stripe only ever sees people who reach the checkout. Everyone who finishes the quiz and stops — the large majority, and exactly the audience a recovery email exists for — would leave nothing behind. We would be storing the data only for the people who least need chasing.

2. It welds the destination into the front end. The quiz is a React build that needs a rebuild and an EC2 deploy to change. Every future change of destination would mean another one.

What we are doing instead: the fields ride the quiz_lead message the quiz already sends, and Django decides where they go. That is one function, on a server we deploy freely.

✅ And this is what makes the Customer.io move nearly free

Today: that function writes to ActiveCampaign. At migration: add a second write to Customer.io in the same function, run both for a fortnight, then drop the AC line. You never open the quiz again. No rebuild, no redeploy, no re-testing the funnel. (For context: Customer.io cannot send at all today — its sending domain has no MX, SPF or DKIM and has never delivered a message. Nothing in this task depends on that being fixed.)

✅ Already done for you — the entire ActiveCampaign side

Created via the API on 6 Aug 2026 and re-confirmed present the same day. Do not create any of these; the ids are stable.

idTag — how far they gotFires when
63770NEW quiz-email-capturedthe email is typed — before any answer is submitted
63771NEW quiz-questions-doneanswers submitted
63772NEW quiz-results-seenthe results screen actually renders
63773NEW quiz-reached-checkoutthe paywall renders — where stampPaywallView already runs
63774NEW quiz-card-startedfirst touch on the card field
63775NEW quiz-all-fundamentals-masteredall 9 fundamentals assessed at level 4

🔴 The field list changed on 6 Aug — twice. This is the final set: 10.

Aga reviewed it and spotted that the goal itself was missing — the one thing that says what a person actually wants. That review turned up several more the quiz already collects and nobody stores.

Then she cut it back, and she was right to: "we might not need all of them — just whatever we will use for marketing." Five were dropped and deleted from ActiveCampaign — Dream Skill, Session Minutes, Frequency, Equipment, Injury Area. They configure the product; they do not change what an email says. The test every survivor passed: would this change a line of copy or a segment? If you see a reference to those five anywhere, it is stale.

idField — who they areMerge tagValue
61TMA Quiz Persona — THE GOAL%TMA_QUIZ_PERSONA%PAIN_FREE · MUSCLE_BUILDER · FAT_BURNER · SKILL_SEEKER · AGE_STRONG · STRENGTH_BUILDER · JUST_START · ALL_MASTERED — the field already exists; it is just never written at LEAD time, only after checkout
64TMA Quiz Obstacle%TMA_QUIZ_OBSTACLE%INJURY · PLATEAU · CONFUSION · CONSISTENCY · TIME_OBSTACLE
65TMA Quiz Future Stakes%TMA_QUIZ_FUTURE_STAKES%free text — their own words
66TMA Quiz Age%TMA_QUIZ_AGE%age band
67TMA Quiz Gender%TMA_QUIZ_GENDER%gender
68TMA Quiz All Fundamentals Mastered%TMA_QUIZ_ALL_FUNDAMENTALS_MASTERED%yes / no
69TMA Quiz Life Context%TMA_QUIZ_LIFE_CONTEXT%screen S5 "What best describes your day-to-day?" — desk work · physically demanding job · juggling family · unpredictable · flexible
70TMA Quiz Background%TMA_QUIZ_BACKGROUND%GYM · BODYWEIGHT · ATHLETIC · IRREGULAR · BEGINNER · INJURED
71TMA Quiz Training Recency%TMA_QUIZ_TRAINING_RECENCY%CURRENT · RECENT · LAPSED · MEDIUM_BREAK · LONG_BREAK · NEVER
77TMA Quiz Weakest Fundamental%TMA_QUIZ_WEAKEST_FUNDAMENTAL%weakest of the 9, from the assessment

🔑 LIFE_CONTEXT is an array — send [0], do not stringify it.

🔴 Naming matters here: it is "all fundamentals mastered" — never "already-elite" or "advanced". It is a measured outcome (every one of the nine fundamentals at level 4), not a description of the person.

🔴 Naming ruling (Aga, 6 Aug): every new-quiz tag is prefixed NEW quiz- — and that changes one thing in your code

All 15 tags carry the prefix so nobody ever mistakes them for the 5,623 legacy tags in the AC tag manager. Consequence: the names your Django code references (PATH_TAGS values and the "quiz-gave-email" literal) NO LONGER EXIST in AC. A name-based lookup would fail even after you fix the limit bug. Use the ids above — or if you keep name lookup, update the strings in the dict to the NEW quiz-* names in the same edit.

Plus 9 tags your CURRENT code already looks for — which did not exist

NEW quiz-gave-email (63776) and the eight path-* tags (63777–63784). Your live code has been trying to apply these for months and silently finding nothing, because they were never created. They exist now.

🔴 Why nothing has been tagged — one thing proven, two things to check

First, the part that is certain — and it is not a code bug

The nine tags your code applies did not exist in ActiveCampaign at all. We pulled the full tag list from the AC API on 6 Aug: NEW quiz-gave-email and all eight path-* tags were absent. _apply_tags() only ever looks a tag up — it never creates one — so every quiz lead hit if not tag_id: continue and silently did nothing. That alone accounts for zero tags, regardless of anything else. We created all nine on 6 Aug, so that cause is now removed.

This IS the current quiz path, not the legacy one — the live bundle (index-DFwXN-qe.js, served at /2026-quiz-checkout-v1/) sends source: "quiz_v3_prodtemp", and activecampaign_sync.py:66 branches on that exact string. POST /api/quiz/quiz_lead answers 400 "email required" in prod right now, so the handler is live.

⚠️ And now the honest caveat on the two below

We read these in our copy of the backend, which is dated 4 April 2026. The file is definitely the one serving the live quiz (proven above), but we cannot see your current tree — so if you have already fixed either of these since April, ignore it and tell us. We would rather flag something you have fixed than stay quiet about something you have not.

Check 1 · the tag lookup asks for 100 of 5,845, then gives up in silence

# app/apps/quiz/activecampaign_sync.py, in _apply_tags()
resp = requests.get(f"{AC_BASE_URL}/api/3/tags", headers=HEADERS,
                    params={"limit": 100}, timeout=10)   # 100 of 5,845
...
tag_id = tag_map.get(tag_name)
if not tag_id:
    continue          # no log, no error, no tag

NEW quiz-gave-email sorts at index 5,705 alphabetically — it can never appear in the first 100. Even now the tags exist, this code still cannot find them. Fix: look up by name (params={"search": tag_name}) or hardcode the stable ids above. 🔴 And log the miss — the silent continue is what hid this for months.

Check 2 · obstacle arrives and is thrown away

def sync_quiz_lead(name, email, primary_path, obstacle, source="") -> bool:

obstacle appears in the signature and nowhere else in the file. The quiz sends it correctly; Django accepts it and drops it. It had nowhere to go because AC had no field for it — field 64 now exists.

Step by step

Step 1 · Write the ten answers into AC ~30m

app/apps/quiz/activecampaign_sync.py — add alongside _apply_tags:

# AC custom field ids — created 6 Aug 2026, stable.
QUIZ_FIELD_IDS = {
    "persona": 61,                      # THE GOAL — PRIMARY_PATH
    "obstacle": 64, "future_stakes": 65, "age": 66,
    "gender": 67, "all_fundamentals_mastered": 68,
    "life_context": 69, "background": 70,
    "training_recency": 71, "weakest_fundamental": 77,
}

def _apply_field_values(contact_id, values):
    # Write quiz answers onto the contact. Never raises.
    for key, field_id in QUIZ_FIELD_IDS.items():
        value = values.get(key)
        if value in (None, ""):
            continue                      # never blank out a real value
        try:
            requests.post(
                f"{AC_BASE_URL}/api/3/fieldValues", headers=HEADERS,
                json={"fieldValue": {"contact": int(contact_id),
                                     "field": field_id, "value": str(value)}},
                timeout=10)
        except Exception as exc:
            logger.warning("AC field %s failed for %s: %s", key, contact_id, exc)

Widen sync_quiz_leadkeep every new argument optional so no existing caller breaks — and call it after _apply_tags. Then pass the values through at the call site, app/apps/quiz/views.py:292.

Step 2 · Send the missing values from the quiz ~20m

PM(e, t) builds the lead payload and already sends {name, email, phone, primary_path, obstacle, quiz_variant, source} — so obstacle is already there. Add these:

persona:                   (t.PRIMARY_PATH ?? "").trim(),   // THE GOAL
future_stakes:             (t.FUTURE_STAKES ?? "").trim(),
age:                       (t.AGE ?? "").trim(),
gender:                    (t.GENDER ?? "").trim(),
all_fundamentals_mastered: ctx.allMastered === true,
life_context:              (t.LIFE_CONTEXT ?? [])[0] ?? "",   // ARRAY
background:                (t.BACKGROUND ?? "").trim(),
training_recency:          (t.TRAINING_RECENCY ?? "").trim(),
weakest_fundamental:       ctx.weakest?.name ?? "",

🔴 persona is the one that was missing, and it is the most important field on the page — it is what they actually want. The field has existed all along and is only ever written after checkout, so for every lead who does not buy we currently do not record their goal at all.

Do not recalculate the last oneallMastered = fundamentals.every(m => progress === 4) is already computed on the results context. And note age/gender already leave the quiz on rR(e), the app's plan-provisioning call — same values, different pipe. Reuse them.

Step 3 · Fire the five stage tags ~45m

One call per stage, fixed tag name by id, async so none of them can slow a page. Reuse stampPaywallView's moment for NEW quiz-reached-checkout rather than adding a new hook.

🔴 Timestamps go in a field or nowhere — never in the tag name. If we want per-stage timestamps later, that is five more custom fields, not five tag-name patterns.

Step 4 · How we will both know it worked ~15m

Run one full quiz with a fresh address, then check that contact: it should carry NEW quiz-email-captured, NEW quiz-questions-done, NEW quiz-results-seen, NEW quiz-gave-email and its path-* tag — and its fields should show the obstacle and the rest populated, not blank.

🔴 Then the real test, and it is a number: ActiveCampaign's tag total was 5,845 when we handed you this. Re-count it. If it went up, a value leaked into a tag name — stop and re-read section 1. A test that only asks "did the code run" would have passed happily through every bug on this page.

Full written spec, with the provenance of every claim: docs/company/ANALYTICS/LEAD_SIGNAL_CONTRACT_2026-08-06.md. One caveat stated there and worth repeating: our copy of the backend is dated 4 April 2026, so function names and the two bugs are near-certainly current but line numbers may have moved. Everything on the ActiveCampaign side and everything in the quiz bundle was read from the live systems on 6 Aug.
2

Make the buyer visible to the machines — one two-line residual left

changes 1–5 ALL SHIPPED ✅ · sweep executed · only RETURN_URL open SCOPE: quiz checkout + ALL checkouts-v3 (Aga, 2 Aug) ~20m left nginx + Django settings NIC-19

✅ 6 Aug — all five changes are shipped. This card is one residual, ~20 minutes.

Everything that was open on this card is closed, and we verified each one ourselves rather than taking the message for it:

  • Change 1 — defer-mint (3 Aug). Curled /static/checkouts_v3/stripe-client.js: no load-time mint, stamp-abandon-email ×3, cancel-incomplete ×2. You chose mint-on-email-blur, one of the two answers that keeps abandon recovery alive.
  • Changes 2 + 3 — closed by the 3 Aug code audit. You had already built both, months ago.
  • Change 4 — the debris sweep, executed 5 Aug. Dry run first, then --execute: 40 matched · 40 cancelled · 0 errors. We re-read all 17 ids this page listed — all 17 canceled, stamped 22:11:21–31Z. The one we flagged to eyeball had no email and no payment method, so no real buyer was caught.
  • Change 4 — /lead is live. POST returns 400 {"status":"error","detail":"email required"}, GET returns 405 Allow: POST, OPTIONS. It 404'd on both verbs on 2 Aug.
  • Change 4 — the success-page gate is live. We probed quiz success with pi_INVALID_PROBE_123: it now 302s away to a checkout page — no "You're in", and no fbq/Purchase anywhere in the served HTML.
  • Change 5 — pe= shipped 4 Aug (NIC-50). The email-revenue gate was due 29 Aug. You cleared it 25 days early.

What is left: the RETURN_URL scheme only — see the rewritten change 4 below, which now carries the diagnosis rather than the symptom.

🔁 Re-verified 5 Aug 2026 — read-only, against live Stripe

Census re-run after your sweep: trialing 9 · past_due 16 — and every single one of those 25 carries a customer email. The anonymous, no-email, no-card class that this whole card existed to kill is at zero. 🔴 One thing worth Aga's eye, not yours: 16 past_due subscriptions with real addresses on them are not debris — that is a dunning population, i.e. real members whose payment is failing. That is a retention lane, and it is now visible precisely because the phantoms stopped hiding it.

📌 Scope ruling — Aga, 2 Aug

Every fix in this card applies to BOTH new checkout estates: the new quiz checkout (quiz.themovementathlete.com → /2026-quiz-checkout-v1/app.themovementathlete.com/payments/quiz-checkout/) and ALL new Stripe checkouts-v3 surfaces (/payments/checkouts-v3/*). Where the quiz estate differs, the card says so inline. This card also absorbed the old task 6 — the email-source join is now change 5 below, so everything lands in one sitting in the same handlers.

🔬 3 Aug — a code audit read the LIVE repos; this card is corrected against it

The audit ran against MovementAthlete-Django (quiz-checkout + checkouts-v3) and MovementAthlete-React-ProdTemp — the actual live code, not the Apr-2026 clone this page previously leaned on. Result: changes 2 and 3 below are CLOSED — you had already built both; change 4's two diagnoses are corrected (RETURN_URL · success page); and change 5 shrank from "build the write" to "capture the recipient + verify arrival" — the UTM write into Stripe metadata already exists on both estates. What's genuinely open: change 1 (phantoms, with a new coupling constraint) and the corrected 4 + 5.

✅ First, what we VERIFIED WORKING — more than we first thought. No action on any of this.

  • The metadata layer works. plan_slug · offer · coupon · conversion_value_usd · first-touch attribution — all arriving exactly as designed.
  • Buyer identity at pay-click works END TO END — we checked the live completed trial. Its customer carries email + name + django_email + django_user_id + consent_at, and the intent metadata carries buyer_email + user_id — including on the SetupIntent (trial) path. An earlier read of this said email/user-link were missing; that was wrong — they land for everyone who clicks pay. Credit where due.
  • Consequence: Stripe's trial-ending + dunning emails DO reach real trial buyers. The "invisible buyer" problem only exists for visitors who never complete checkout — which is the phantom-subs problem below, not an identity-capture problem.
  • The 3 Aug code audit adds three more "already built":stamp-abandon-email writes buyer_email onto the intent AND calls Customer.modify(email) — old change 2, closed. ② Completed buyers are provisioned by checkouts_v3_provision.provision_buyer_from_intent (Django user, django_user_id on the Customer, AC lead + plan tag) — both success pages call it — old change 3, closed. ③ Both estates already flatten utm_* / gclid / fbclid into Customer/Subscription/PI metadata at create-intent — see the corrected change 5.

The one structural problem

The subscription is born before the buyer exists. startCheckout() runs on page load — before any field is touched — and the server creates a Customer + Subscription right then.

Estate nuance (3 Aug audit): on v3 the mint is unconditional — startCheckout() on load, every visitor. On the quiz estate it is conditional but effectively always-on for the funnel that matters: maybePrefetch()ensureIntent() fires on DOMContentLoaded whenever the email is prefilled — which is every arrival from the quiz handoff. Same phantom effect, different trigger — the quiz half of the fix is NIC-41 (task 7); change 1 here is the v3 half.

So every visitor who opens a checkout page and leaves mints an anonymous "trialing" subscription. The census of all 94 v3 subscriptions ever created (30 Jul evening, fully paginated — an earlier version of this page said 78/4 past_due; that scan truncated at the newest 100 subs and the adversarial re-check caught it. States churn daily — the 2 Aug re-run is in the banner above: 18 trialing all-phantom, 17 past_due, and the quiz estate adds its own pattern: 17 incomplete subs, all 17 with an email, plus 25 already expired — 42 reachable typed-email abandoners — that lane is now with Aga, AGA-57):

What Stripe showsCountWhat it really is
trialing · card + email ✅1The only real trial
trialing · no card, no email16Abandoned page-views posing as trials — never self-clean
incomplete_expired39Abandoned paid-path visits (these DO self-expire ~23h)
canceled16Tests + expired abandons
past_due17Phantom "trials" that ended — failing invoices addressed to nobody, accruing at ~8–9/week since 17 Jul
active5Your coupon test buys ($24.97 price, $0.25 paid via 99OFF2)

Bonus churn (v3): applying a coupon calls recreate() → a second customer + subscription per visitor, without cancelling the first. The quiz estate has no coupon recreate — but its plan switch remints client-side without cancelling the prior incomplete sub. Same debris class, both estates.

🔓 What this enables — honest today-vs-after (two rows already work thanks to your pay-click stamps)

MachineTodayThe day this ships
Trial-conversion email🟡 Possible for REAL trials (your pay-click stamps made it so) — but our jobs must filter 16 phantoms per 1 real to find themClean feed: "trialing" = a real buyer. Day-3 value + day-6 "your trial ends tomorrow" naming their actual plan + price, no filtering gymnastics
Stripe's own trial-ending + dunning emails✅ Work for real buyers alreadyStop ALSO firing failed-invoice machinery at ~8–9 new phantom past_dues a week
Cart-abandon recovery🟡 Corrected 3 Aug: v3's stamp-abandon-email already persists typed emails (buyer_email + Customer.modify) — the machines just aren't reading them yet. Quiz persists them separately in Redis (stamp-paywall-view) — that lane is Aga's, AGA-57Every typed-email abandoner is recoverable — the highest-intent free traffic we have — and change 1's coupling keeps it that way
Payment-recovery (dunning) on v3🟡 Reaches real buyers; wades through phantom invoices addressed to nobodyEvery open invoice in the scan is a real member with a real address
Metrics❌ 78 "subscriptions", 1 real trial — every trial count, churn rate and abandonment read on v3 is noiseDashboards tell the truth without hand-filtering

The five changes — where · what · how you know it worked

The snippets below are the shape of each change, written against Stripe's Python API — adapt to your named handlers (checkouts_v3_stripe.py · views_quiz_checkout.py / quiz_checkout_stripe.py), never paste as drop-in. Revised 30 Jul evening: two of the original five changes (identity on the customer, user link) were already built by you — moved to the green card. Revised 2 Aug: the email-source join (old task 6) folded in as change 5. Revised 3 Aug — the code audit read the live repos: changes 2 and 3 closed as already-built; 4 and 5 corrected in place. What remains below is genuinely open.

1 · ✅ SHIPPED 3 Aug — phantom minting stopped on v3. Nothing to do; kept for the record. DONE · verified live

Done. You deferred create-intent to valid email blur (or a prefilled warm email), kept stamp-abandon-email fed, and made coupon recreate() cancel the prior incomplete sub. Verified by us in the served JS the same day. The original instruction follows only so the reasoning is not lost.

Preferred Don't create the subscription at page load. Create it at pay-click (or email-blur) — the client hooks already exist; create-payment-intent just moves later in the flow.

Stopgap you can ship in 5 minutes today

# at trial-subscription creation — phantoms then die at trial end instead of going past_due
stripe.Subscription.create( ...,
    trial_period_days=7,
    trial_settings={"end_behavior": {"missing_payment_method": "cancel"}} )

Plus a nightly sweep for the ones already sitting there

# BOTH states — trialing phantoms AND the past_due junk they age into.
# auto_paging_iter matters: a limit-100 scan misses older debris (we made that mistake ourselves).
for status in ("trialing", "past_due"):
    for sub in stripe.Subscription.list(status=status, limit=100).auto_paging_iter():
        if (sub["metadata"].get("checkout_version") == "v3"
                and not sub.get("default_payment_method")
                and not stripe.Customer.retrieve(sub["customer"]).get("email")
                and time.time() - sub["created"] > 86400):
            stripe.Subscription.cancel(sub["id"])

Also: when coupon-apply calls recreate(), cancel the previous sub/intent before creating the new one — right now every coupon user mints a duplicate set. (Quiz estate: same rule for the client-side plan-switch remint — cancel the prior incomplete sub.)
⚠️ Coupling constraint — from the 3 Aug audit: stamp-abandon-email needs an intentId to stamp. If you defer create-intent to pay-click, typed-email abandoners lose their Stripe stamp — so either mint at email-blur instead of page-load, or persist the email server-side the way the quiz does (Redis-style stamp). Deleting the early mint without one of these blinds abandon recovery.
Test: open a trial page, close the tab → 24h later no trialing sub from that visit exists — AND a visitor who typed an email before leaving is still captured somewhere real.

2 · ✅ CLOSED — typed-email abandoners are already captured. Nothing to do.

What we asked for, and what the code actually says This change asked you to confirm and extend stamp-abandon-email. The 3 Aug audit read the handler: it already writes buyer_email onto the PaymentIntent/SetupIntent AND already calls Customer.modify(email) — which was the "extend" half. Both halves exist. We saw no stamped intent in the live scan simply because almost nobody had typed an email and left yet, not because the write was missing. Our error, corrected — no work here.

The one thing to carry forward This mechanism is v3-only. The quiz estate does not write a Stripe buyer_email for abandoners — it stamps stamp-paywall-view (email, plan, path, pending password) into Redis/cache and queues via quiz_checkout_abandon.py. Two different systems — do not port one onto the other. The live consequence is the coupling warning in change 1: if v3's create-intent moves later, stamp-abandon-email has no intent to stamp.

3 · ✅ CLOSED — provisioning is already built. Question answered without you.

We asked where completed v3 buyers get provisioned. The audit found it: checkouts_v3_provision.provision_buyer_from_intent — it creates the Django user, writes django_user_id onto the Stripe Customer, and creates the ActiveCampaign lead + plan tag. Both success pages call it (quiz-checkout and checkouts-v3 share this one path). The djstripe customer.subscriber worry was misplaced — you provision directly and never depend on that signal chain. Line closed for good, as promised.

4 · ✅ Sweep + ALL FOUR bugs are DONE — RETURN_URL verified https:// at the 7 Aug PM re-probe

  • ✅ The past_due phantoms — cancelled. All 17 ids this card listed are canceled; your run reported 40 matched / 40 cancelled / 0 errors across both states. Nothing further.
  • /lead — live. Verified on both verbs. The exit-intent addresses land.
  • ✅ The success page — gated. A junk intent id no longer renders a success state or fires analytics.
  • RETURN_URL — fixed and verified. Re-probed 7 Aug PM: all three checkouts-v3 pages now render window.RETURN_URL = "https://…/checkouts-v3/success"; the only http:// remaining on any page is a New Relic internal constant. The diagnosis below is kept for history; no action remains. (We did not re-test HSTS on app. — optional hardening, your call.)

Severity, honestly Low. The server already redirects the bare http:// success URL up to https://, so buyers do end on TLS. But the hop is in the clear and there is no Strict-Transport-Security header on app. to prevent it, so it is a real defect rather than a cosmetic one. Fix it when convenient, not before the noindex hosts.

The diagnosis — so this is not a hunt We went looking rather than handing you "verify if still broken". In the repo copy we hold, both halves of the TLS signal are missing:

# nginx/sites-enabled/django_project — BOTH proxy blocks (~L42-44 and ~L64-66)
# set Host, X-Real-IP and X-Forwarded-For ... and never X-Forwarded-Proto.
proxy_set_header Host              $host;
proxy_set_header X-Real-IP         $remote_addr;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;   # <-- add this, in BOTH blocks

# Django settings — SECURE_PROXY_SSL_HEADER appears NOWHERE in app/.
# Without it Django ignores the header even once nginx sends it.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

With both in place request.build_absolute_uri reports https and RETURN_URL corrects itself — no change to the checkout code at all. ⚠️ One honesty caveat on that diagnosis: our copy of the backend is a 2 April 2026 clone, so treat the two file locations as a strong hypothesis rather than confirmed-current. The symptom is confirmed live today; if your tree already sets one of the two, the other is the one that is missing. Test: curl any v3 checkout page and grep RETURN_URL — it should read https://.

5 · ✅ SHIPPED 4 Aug — the email-revenue join is closed. Nothing to do; kept for the record. DONE · NIC-50

⚠️ Our premise was wrong — corrected 3 Aug. This change said "nothing writes the email source into the Stripe purchase record." That is false. The audit found that both estates already flatten utm_*, gclid and fbclid into Customer / Subscription / PaymentIntent metadata at create-intent. The write exists. What's missing is narrower — see below.

Where the join actually breaks Aga fixed her half on 31 Jul: AC links now carry UTMs (live audit 2 Aug: 908 of 1,101 links tagged, 82.5% — was 4.1%). The Stripe write exists. So when a purchase shows empty UTMs, the cause is upstream — the visitor arrived at the checkout URL without them (bare landing, a redirect that stripped the query string, or v3 first-touch storage never populated). That is task 2's territory, not a missing write here.

What is genuinely yours (~30m)

  1. Add the recipient email to the flatten list. The utm_* keys already travel; the pe= param (the AC recipient) does not. Same code path, one more field — on both estates.
  2. Confirm the offer-ladder surfaces hold it. /start-training · /30trial · /50promo are where tagged email links land before the checkout — if they drop the query string on the way through, the metadata arrives empty no matter how good the write is. One probe each with ?utm_source=actest&pe=x@y.com.
  3. The read-back (revenue split by utm_campaign/utm_content) is our job, not yoursrevenue_per_source_pull.py gets extended the day the recipient field exists.

Test: one purchase driven from a tagged email link arrives in Stripe with utm_source and recipient in metadata. Email is the one channel where we own the audience (≈6,000 engaged-reachable contacts, zero media cost) and it was the Email fleet's 90-day gate, due 29 Aug — cleared 25 days early. (Doc pointer, unchanged: this is Analytics Brief tasks 27/31; the last-funnel-step export is task 11 and stays separate.)

How we'll both know the whole task worked

DoExpect
One test trial (test card)Regression check — customer shows email + name + card + django_user_id, intent metadata has buyer_email (all already working today; must stay true after the refactor)
Open a checkout page, close the tabNo trialing subscription survives 24h
Type an email into a v3 checkout, then leave without payingStill capturedbuyer_email on the intent + email on the Customer. This works today; the change-1 refactor must not break it
POST the exit-intent formThe address lands somewhere real (not 404)
Land on the success page with a junk intent idNo success state rendered, no GA4/pixel fire (provisioning already refuses — that half needs nothing)
Buy from a tagged email linkStripe metadata carries utm_source and the recipient
Tell us it's liveWe re-run the sub census (read-only, one minute) → zero new anonymous trialing subs, and we flip the email machines on
2

Carry the UTMs into the checkout — the last broken link in the chain

✅ DONE 4 Aug · was P0, verified live SCOPE: BOTH estates (Aga, 2 Aug) ~45m quiz/paywall → checkout link NIC-16

🔴 Do the 10-minute test BEFORE you write anything here

This may not be a build at all. The 3 Aug code audit of the live repos found that handoffQuizCheckout2026.ts already copies utm_*, gclid and fbclid off location.search, and that both Django estates already flatten them into Customer/Subscription/PI metadata at create-intent. So "0 of 30 intents carry utm_source" most likely means the checkout URL arrived empty — an upstream gap — not that a write is missing.

The test: one purchase with ?utm_source=nictest&utm_campaign=verify on the quiz URL, then read the Stripe intent metadata. Present ⇒ the quiz half is already closed and the only real work is first-touch rehydrate for bare landings (visitors who arrive with no query string at all). Absent ⇒ debug qsAttr / create-intent. Never rebuild the handoff blind. This same test is also the fastest proof of legs 1 and 2 of the email-revenue chain in task 1 change 5.

Next, the good news: your checkout is doing its job. We read /payments/checkouts-v3/trial/12month/ live and traced the whole path. The attribution shim is served (/static/checkouts_v3/attribution.js, 200), stripe-client.js posts attribution: getAttribution() into create-payment-intent, and your server writes it into Stripe metadata. All three legs work. We also confirmed first-touch persistence is working — one intent created on 28 Jul still carried a first_seen of 21 Jul.

The problem is what is inside it. Across the last 30 checkouts-v3 payment intents, the attribution object looks like this in 29 of 30 (the thirtieth carries no attribution at all):

{"first_seen":"2026-07-28T22:13:01.394Z",
 "landing":"/payments/checkouts-v3/normal/12month/",
 "referrer":null,
 "page":"/payments/checkouts-v3/normal/12month/",
 "ua_webview":false}

No utm_source. No utm_campaign. No gclid. Referrer null. The shim can only read UTMs from the checkout page's own URL — and visitors are arriving there with a bare URL, because whatever links them from the quiz/paywall to the checkout does not carry the query string across.

The contrast proves it is not the shim's fault. On the older /v2_checkout/ estate, where paid ads land people directly on the checkout, the same metadata comes through fully populated — utm_source=google, utm_campaign=tma_lifetime_retarg_demandgen, a real gclid, and a landing_path. Same mechanism, different journey: those visitors arrive with UTMs on the URL.

🔁 Re-verified 2 Aug — and the quiz estate has the same hole, with proof

Re-run against live Stripe (read-only): of the last 30 checkouts-v3 intents, 29 carry an attribution object and 0 — zero — carry any utm_source. Unchanged since 30 Jul.

New evidence from the quiz estate (Aga pulled the specimen, 2 Aug): a real $97 purchase attempt (checkout_version: quiz_checkout) carries beautiful metadata — persona, path, plan_id, quiz_variant, screen_id, offer — and not a single utm_* key. Also: quiz-estate PaymentIntents carry no metadata at all (identity lives only on the Customer/Subscription), which is why they are invisible to every intent-level scan. So the quiz side needs two things: forward the query string into the checkout, and stamp the attribution into subscription_data.metadata and the intent — this is the revenue-plan task 29, folded in here because it is the same fix. The v3 attribution.js pattern is the port.

🔬 3 Aug — the code audit changes what you should DO here (read before starting)

Two of this card's instructions turned out to be already-built. From the live repos:

  • "Append the query string on the quiz→checkout handoff" is ALREADY DONE. handoffQuizCheckout2026.ts copies utm_*, gclid and fbclid straight off location.search. Do not rewrite the handoff.
  • "The checkout only reads path/persona" is FALSE. Django also reads email, plan and the UTMs, and the Stripe _metadata write includes them when present. The write is there.

So the real gap is upstream of both: the handoff copies what is on the URL, and does not rehydrate from the stored first touch (Amplitude / sessionStorage tma_quiz_first_touch) when the current quiz URL is bare. Bare landings — someone arriving from an ad that dropped its query string, or a second-session return — are what produce "0 of 30 intents carry utm_source".

What to do — in this order

  1. Run the acceptance test FIRST (10 min). One purchase with ?utm_source=nictest&utm_campaign=verify on the quiz URL → we read the Stripe metadata. This is a diagnostic, not a formality — it tells us which half is broken before you write anything.
  2. If utm_source: "nictest" arrives: the quiz forward-path is proven working and closes here. The remaining loss is bare landings only → move to step 4.
  3. If it does not arrive: the break is in qsAttr / create-intent, not in the handoff. Debug there. Do not rebuild the handoff blind — it already does the copy.
  4. First-touch rehydrate (the genuine new work). When the current URL carries no UTMs, read the stored first touch (tma_quiz_first_touch) and use it in the handoff. Same fix conceptually for v3 bare landings, where first-touch storage exists but arrives empty.

How we will both know it worked

Two probes, not one. (a) A purchase with ?utm_source=nictest on the URL → utm_source: "nictest" in the Stripe metadata. (b) A purchase from a bare quiz URL, in a session that first arrived tagged → the metadata still carries the original source. (b) is the one that proves the rehydrate. Each takes us a minute to confirm read-only.

Why this is worth the time: right now we can tell Aga which channel produced a lead, but never which lead produced a sale — because the source does not survive to the payment. This closes the loop from article/video → lead → cash, which is the number the whole growth plan is steered by. The 3 Aug correction makes it cheaper, not less important: most of the plumbing is already yours — what is left is proving where the source evaporates and covering the bare-landing case.
3

A test domain is baked into the live quiz checkout bundle

✅ DONE 4 Aug · was re-opened 3 Aug, then shipped and verified ~20m frontend build NIC-30 was NIC-22

🔴 Re-verified 3 Aug, AFTER your two checkout ships — still open, and untouched by them

Your ships were Django handlers; this one is the React bundle, so it was never going to move with them. We re-ran this card's own acceptance test today: quiz.themovementathlete.com/2026-quiz-checkout-v1/ still serves index-CEOCAHYQ.js — the identical bundle hash this defect was filed against, so no rebuild has been deployed. Curled it (1,338,941 bytes): 7 hits for app-test, all VITE_REGISTER_PAY_URL. addToCartValue:0 still present — and addToCartValue:25 is present alongside it, which confirms the audit's correction that the zero is on 1wk_trial, not on monthly_trial. Please pair this with task 4: one rebuild+redeploy closes both.

🔁 Re-verified 2 Aug — still live, cache-busted

A fresh cache-busted fetch of the shipped bundle (index-CEOCAHYQ.js — same bundle, no redeploy since) still returns 7 hits for app-test and addToCartValue:0 is still present (1 hit). Both defects stand exactly as written below. Reminder of the stakes: the fallback register route on app-test.… has been observed serving a Stripe TEST key — if that route ever fires, a real buyer "pays" into a test account.

🔬 3 Aug code audit — two refinements before you start

  • Check the repo before you edit it. The audit confirms the defect in the deployed dist-2026-quiz-checkout-v1, but .env.production in the repo may already point at production. The dist can disagree with source — in which case the whole fix is rebuild + redeploy to EC2, with no source edit at all. Check source first; it may save you the edit entirely.
  • addToCartValue: 0 is on 1wk_trial, not monthly_trialcorrected 3 Aug; the line below was wrong. monthly_trial correctly sends 25. Fix the 1wk_trial entry.

⚠ Reopened 31 Jul — this was marked done while the defect is still live

No resolution was recorded, and a cache-busted fetch of the current bundle (index-CEOCAHYQ.js) still returns 7 hits for app-test, including VITE_REGISTER_PAY_URL. Not a criticism — a process note: this one closes on a grep of the deployed bundle, not on intent. The acceptance test below takes ten seconds.

First, the good news: the checkout is correct on the path that matters. Verified in the shipped bundle 31 Jul — quiz.themovementathlete.com redirects to /2026-quiz-checkout-v1/, annual charges $97 today, ramps are $24.98 / $12.48, addToCartValue is populated (97 / 25 / 12), tma_ckt_out is set on hand-off, the exit modal fires, and UTMs including gclid/fbclid are forwarded. Two things turned up on the fallback route.

The two defects
  1. The production bundle ships a test domain. VITE_REGISTER_PAY_URL: "https://app-test.themovementathlete.com/register/yearly_2026/". This is the fallback register route — the primary (app.themovementathlete.com/payments/quiz-checkout/) is right — but if the fallback ever fires, a real buyer is sent to a test domain to pay. Point it at production, or remove the fallback.
  2. The same fallback block still sends addToCartValue: 0 — on 1wk_trial (corrected 3 Aug: this card previously said monthly_trial; that entry is fine at 25, and annual_trial sends 157). Harmless while /50off/ is the live path; a Meta-optimisation bug the moment the fallback is used — a 0 teaches Meta to find people worth nothing.
Done when

The deployed bundle greps clean: zero hits for app-test, and no plan object left carrying addToCartValue: 0 (check the 1wk_trial entry specifically). Companion: task 4 (the dead-button trace) lives in this same fallback block — do both in one sitting.

4

Make the dead Buy button speak — same file as task 3, one sitting

extracted from the retired on-page-port task · revenue-plan task 19 ~20m quiz checkout fallback block NIC-37

What it is, in plain terms: in the quiz checkout handler there is a branch that runs when the tapped plan doesn't match any plan object — and it does nothing. No event, no message. The visitor taps Buy, nothing happens, and they vanish from every number we have. We cannot even say how often it fires.

🔬 3 Aug audit — which surfaces this actually affects

The dead branch is R10Paywall, non-control variants: no checkoutUrl ⇒ silent return before add_to_cart ever fires. The live quiz_checkout_2026_v1 variant does NOT hit this handler — it auto-hands-off instead. So this is not costing us sales on the main funnel today. Still worth the 20 minutes, because it protects the other variants, the exit flow and the control — the surfaces we test against — and a silent no-op is the one failure mode no dashboard can ever show us.

How
  1. It's the same fallback block task 3 opens (the one carrying VITE_REGISTER_PAY_URL and the addToCartValue map) — do both in one sitting.
  2. In the no-match branch: fire an analytics event checkout_button_dead with the plan id and screen, and render a visible error — "Something went wrong — refresh and try again." Anything but silence.
  3. Test: force a mismatched plan id in a test build → the event appears in GA4 DebugView and the message renders.
Done when

A forced plan-mismatch click leaves a trace (event + visible message) instead of silence — and from that day we know whether this branch fires once a year or fifty times a week.

Abandoned-cart capture rebuild — moved to Aga (3 Aug ruling)

MOVED · now AGA-57 · Aga is handling it in a dedicated session

Aga, 3 Aug: "remove this task for Nic, I am handling this now." The lane build (Stripe abandoners → the abandonment emails, purchase exclusion evaluated at send time) is hers, tracked as AGA-57. The verified facts travel with it: 42 typed-email, reached-payment abandoners already sit in Stripe on the quiz estate (17 incomplete, all with emails, + 25 expired; ~14/day accruing), and none of the 20 scheduled jobs mails them today.

Your only related piece stays in task 1, change 2: the buyer_email stamp + Customer.modify on the v3 estate — that is what makes v3 abandoners visible to whatever lane Aga builds. Nothing else here is yours.

Give the quiz estate the purchase signal v3 already has — SHIPPED

folded from revenue-plan task 11 · rescoped 3 Aug by the code audit DONE · closed on Slack proof quiz success page NIC-34

✅ 6 Aug — CLOSED. You shipped it and you proved it.

Quiz success now fires browser fbq Purchase with eventID = the PaymentIntent id, so it dedupes against the server CAPI webhook that was already running. Closed on the two Meta Pixel Helper screenshots you sent Aga on Slack, captured on app-test under Stripe test mode.

Being precise about what that evidence is, because you should know the standard rather than guess at it: those screenshots prove the code fires and carries the right eventID. They are an app-test capture, not a production one. We are not asking you for a production capture — it arrives free with the first real quiz purchase in Meta Events Manager, and chasing one now would mean running a live card through the funnel for no additional information.

Why we could not verify the quiz half ourselves — and it is a compliment: quiz success correctly 302s without a valid intent (your own success-gate ship), and the fbq lives inline on the template rather than in a static file, so there is nothing for us to curl. /static/quiz_checkout/analytics.js 404s and stripe-client.js carries zero fbq. The v3 half we did verify live: /static/checkouts_v3/analytics.js, 3,261 bytes, fbq ×2 · eventID ×1 · Purchase ×3.

Background — the server-side CAPI webhook already existed

This task asked you to build the server event. You already built it. The 3 Aug audit found it live: /payments/analytics/stripe-purchase/ (analytics_stripe_purchase) fires Meta CAPI on payment_intent.succeeded with event_id = the PaymentIntent id — exactly the dedup contract we were about to ask for. Combined with /static/checkouts_v3/analytics.js on the browser side, checkouts-v3 is complete. Do not build this again. The estimate drops from half a day to about an hour.

What the task was — all three now done, kept for the record:

  1. Quiz success is dataLayer-only. It does not fire an fbq Purchase, and it does not carry an eventID. Give it the same contract v3 has: browser fbq Purchase with eventID = the PaymentIntent id, so it dedups against the webhook that is already running. Value + currency from the intent, not the page.
  2. Confirm the webhook covers the quiz estate's intents too — same handler, but worth one look, since the quiz path creates its intents through quiz_checkout_stripe.py.
  3. Pixel proof (5 min, ops not code): open a checkouts-v3 page with Meta Pixel Helper against GTM-TKGDZR9 and confirm the pixel actually loads — assumed, never proven. If Aga is quicker to this than you, she can do it — it needs no repo access.
Done when

A test buy on the quiz estate shows in Meta Events Manager as ONE deduplicated purchase (server + browser), matching what v3 already does today.

On-page checkout port — retired as superseded (Aga, 3 Aug)

RETIRED · the 31 Jul ship replaced its premise · 14-Aug read decides any successor was revenue-plan tasks 28 + 19 ~6–8h quiz paywall · Stripe Elements

Why this was retired — the full story, verified live 3 Aug

Where the task came from: the revenue plan of 29 Jul — two days before the new quiz checkout shipped. Back then, tapping Buy on the quiz paywall sent people into the old /register/ signup flow, and the measurement showed 604 Buy-taps producing only 248 subscription starts — the hop was where the rest died. The fix proposed: mount the card form directly under the price tiers, no hop at all.

What the 31 Jul ship changed: the new flow already collapses most of that. Verified against the live bundle 3 Aug: the quiz's pricing screens contain zero Stripe code (no card fields in the bundle) — a Buy tap hands off once to app.themovementathlete.com/payments/quiz-checkout/, the combined price-plus-card page (a real buyer's Stripe metadata carries screen_id: QuizCheckoutHandoff). So today it is: pricing screen → one hop → payment page. Vastly tighter than the flow the 604→248 number was measured on — which makes that number obsolete as this task's justification.

The ruling: whether today's single hop leaks anything is unknowable until the 14-day read of the new checkout (~14 Aug — already the plan's kill-switch read). If Buy-taps → payment-page arrivals → paid reads healthy, nothing is ever built here. If the hand-off shows a real leak, a successor task gets scoped with fresh numbers as its reason. The one fragment that stayed alive — the dead-button instrumentation — is now task 4, folded next to task 3 because both live in the same fallback block. The original spec is kept below purely for the record.

✓ The deploy gate in front of this is CLEAR — verified 2 Aug

The revenue plan held this build behind "why does /api/quiz/config 404?". Live check 2 Aug: it returns 200 now. Nothing blocks this task except tasks 1–2 (do those first — the port then inherits handlers that are already fixed).

Why it is worth 6–8 hours: 604 buy-clicks produced 248 subscription starts. The redirect from the quiz paywall to app.themovementathlete.com is where the other 356 die. You already proved the on-page pattern on checkouts-v3 — this is a port, not an invention.

  1. Mount Stripe Elements under the tiers on the quiz paywall, on the v3 contract: attribution.js + create-payment-intent/update-payment-intent — the buyer never leaves the page.
  2. Inside the same build, give the dead button a voice (old task 19): the handler branch that silently no-ops when a plan doesn't match must emit an analytics event (checkout_button_dead with the plan id) and show the visitor an error — today that person vanishes from every number we have.
  3. Keep the fallback register route OUT of the bundle (task 3 removes app-test; don't reintroduce a fallback that can point anywhere but production).
Done when

A test buyer completes payment without leaving the paywall, the attribution lands per task 2's acceptance test, and a forced plan-mismatch click leaves a trace instead of silence.

YouTube / magnet lead counting — moved to Aga (3 Aug ruling). Instructions kept here for her.

MOVED · now AGA-58 · Aga does the Leadpages + GTM clicks herself ~45–60m

✓ Nic: nothing in this card is yours — Aga took the wiring on 3 Aug (AGA-58)

Kept on this page because the click-by-click below is what Aga works from. The diagnosis was run 2 Aug (GA4 Data API, property 177471782, 28d); the verdict and evidence follow.

The verdict — neither suspect A nor suspect B as written

C — a landing-mix + measurement gap. YouTube's traffic overwhelmingly never goes near the quiz: 96% of its 3,810 sessions land on main-site lead-magnet pages, and generate_lead only exists on the quiz subdomain — so even a converting magnet visitor is structurally invisible. The numbers:

Measured (28d, GA4 API)Value
YouTube sessions (youtube/organic + youtube.com/referral)3,695 + 115 — the brief's claim verified exactly
Where they land/calisthenics-assessment/ 1,991 · /free-28-days-calisthenics-challenge/ 938 · handstand PDF 271 · muscle-up 107 · … — all themovementathlete.com magnet pages. Only ~44 sessions ever reach the quiz host
Where generate_lead firesquiz.themovementathlete.com: 353 of 353 — it does not exist anywhere on the main site
Form events on the magnet pagesZero. 2,515 page_views on /calisthenics-assessment/ produced no form_start, no form_submit — the embedded AC forms are invisible to GA4's enhanced measurement
Leads credited to YouTube1

So suspect B ("the traffic doesn't convert") is unprovable either way from GA4 — conversion on the pages YouTube actually lands on is simply not instrumented. And suspect A (quiz-hop UTM loss) affects at most the ~44 quiz-host sessions. The honest reading: we cannot count YouTube's leads until the magnet pages report form submissions.

✓ Plus a fresh suspect-A regression — found 2 Aug, FIXED and verified closed 3 Aug

Sometime after 31 Jul, /calisthenics-assessment/ — YouTube's #1 landing page — acquired a Redirection-plugin 301 to the quiz root. WP Engine's edge cache serves the cached bare-URL 301 to every utm-only visitor, stripping utm_source/_gl in flight (the link-integrity guard regressed 19/0 → 18/1 on exactly this URL; mechanism proven with a control parameter — non-utm params reach the page, utm-only requests get the cached 301).

Closed, nothing for you: the tag-preserving client-side redirect is live on the page (Code Snippet 9, now with a no-JS fallback), Aga deleted the plugin rule on 3 Aug (AGA-54, done), and the link-integrity guard re-ran clean: 19 preserved · 0 dropping, exit 0. Same hop, same destination, attribution intact — verified end-to-end.

The one thing left for you (~45–60m) — final version, corrected twice against the live pages

How the magnet forms ACTUALLY work (read this first — it kills two wrong approaches)

The email forms are Leadpages pop-ups ("Leadboxes") rendered inside a cross-origin iframe (embed.lpcontent.net). Two consequences, both verified:

  • GTM on our pages cannot see the submit — the built-in Form Submission trigger and Element Visibility tricks are dead ends here (the earlier version of this card suggested them; ignore that).
  • The UTM capture into ActiveCampaign already works — page URL → iframe URL → hidden fields → AC contact (proven by tools/analytics/check_magnet_utm_capture.mjs, 8/10 magnets live-verified). You are NOT building attribution; you are only telling GA4 that a lead happened.

The one reliable hook: an after-submit redirect to a thank-you page — configured inside Leadpages. So yes: this task needs the Leadpages login (app.leadpages.com).

✅ Already built for you — the shared thank-you page is LIVE

themovementathlete.com/resource-thank-you/ (created 3 Aug, verified 200) — "check your inbox" copy + an assessment CTA. ALL ten pop-ups point at this ONE page; each appends its own ?magnet=<slug> so we can still tell which magnet converted. You do not create any pages.

✅ Step A is DONE — Aga swapped all ten pop-ups on 6 Aug 2026

Every magnet pop-up now redirects to /resource-thank-you/. The table below is kept as the record of what was set, not as work to do. 🔴 Leadpages SAVED is not PUBLISHED — if any magnet later stops redirecting, that is the first thing to check.

Step A — Leadpages (~2 min per pop-up × 10)
  1. Log in at app.leadpages.com → left menu Conversion Tools → Pop-ups (older UI calls them Leadboxes).
  2. Open each of the ten magnet pop-ups → click the form / button widget → find the after-submit / thank-you setting → choose "Go to URL" (instead of the inline thank-you message) → paste that magnet's URL from this table → Update/Publish:
Pop-up (magnet)Paste as the after-submit URL
28-day challengehttps://themovementathlete.com/resource-thank-you/?magnet=challenge
Handstand PDF…/resource-thank-you/?magnet=handstand
Muscle-up plan…/resource-thank-you/?magnet=muscle-up
Planche guide…/resource-thank-you/?magnet=planche
Back-lever…/resource-thank-you/?magnet=back-lever
Front-lever…/resource-thank-you/?magnet=front-lever
Ring dips…/resource-thank-you/?magnet=ring-dips
Shoulder mobility…/resource-thank-you/?magnet=shoulder-mobility
Press to handstand…/resource-thank-you/?magnet=press-to-handstand
Get started…/resource-thank-you/?magnet=get-started

One check per box while you're in there: if a pop-up currently shows a direct download link after submit (instead of "check your email"), the redirect would hide it — tell us which one and we add that file's link to the thank-you page. The delivery email from AC keeps working regardless.

Step B — Google Tag Manager — ✅ ALREADY DONE, SKIP IT

✅ The fleet built and published this on 6 Aug — nothing for you here

Container GTM-TKGDZR9, published as version 116 (verified by reading the live version back). Variable URL - magnet (query) (URL / Query / key magnet) · trigger TMA - Lead Magnet Thank-You Pageview (Page Path contains /resource-thank-you/) · tag GA4 - generate_lead (magnet)G-ZBQN3P8WKW with lead_type=magnet and magnet_slug. Do not rebuild it — a duplicate tag double-counts every lead.

One thing that was silently broken and is now fixed: the thank-you page is served by a WordPress code snippet that bypasses the theme header, so the site-wide GTM container was not loading on that page at all (0 references, against 3 on any normal page). The container tag was injected into the snippet on 6 Aug and verified live. Worth knowing generally: any snippet-served page is invisible to everything installed in the theme.

Step C — the end-to-end test (ten seconds)
  1. Open /free-28-days-calisthenics-challenge/?utm_source=nictest, submit a test email in the pop-up.
  2. You should land on /resource-thank-you/?magnet=challenge.
  3. GA4 (analytics.google.com) → Realtime: a generate_lead event appears; in DebugView its session source reads nictest.
If the Leadpages login cannot be found: say so and stop — the fallback needs no Leadpages and no GTM at all: the leads already arrive in ActiveCampaign carrying their utm_source (verified), so the fleet can count magnet leads by source from AC directly. GA4 stays magnet-blind in that world, but the YouTube question still gets answered.
Done when

Magnet submissions appear as generate_lead with real sources — from that day the YouTube lane (and every magnet lane) counts. YouTube fleet gate: due 22 Aug.

Make a free test account — 10 minutes, unblocks the free-member audit

MOVED · now AGA-66 · Aga is handling this

Three questions about what a free member sees cannot be answered from outside the login wall (which upgrade CTAs exist, what the paywall shows a logged-in free user, whether the $9.99 step-down is reachable). Create a throwaway free account and send the credentials to Aga — the audit runs the same day.

Decide with Aga: does /register migrate to checkouts-v3 or retire?

DECISION · revenue-plan task 32 · not build work yet

We run two checkout estates on two different analytics contracts, and nobody has decided which survives. Every task on this page doubles in maintenance until this is ruled. Bring a recommendation to Aga (15 minutes of thought, no code); she rules; the loser gets a retirement date. Parked here so it is not invisible — do not start migrating anything until the ruling exists.

Front-lever cluster consolidation — off your list (7 Aug): the evidence downgraded it, the fleet executes

✅ MOVED TO FLEET · ranked lastNIC-07

Why it left your brief: the keyword map this card waited on landed 6 Aug (marketing/seo/NIC82_FRONT_LEVER_KEYWORD_MAP_2026-08-06.md, Aga-approved) and it refuted the premise. The bare skill name is a zero-click SERP (61.8–71.8% zero-click across the clusters; planche = 135,725 impressions → 97 clicks), so consolidation is worth ≈+1.1 leads/28d [MODELLED] across all three clusters — a 20-minute hygiene task, not a project: 3 × 301 (/improve-front-lever/ · /how-to-do-a-front-lever/ · /front-lever-variation//front-lever-progression/) + 6 internal-link repoints, with an explicit DO-NOT on the tempting fourth merge. The fleet applies these itself via the Redirection REST API — the same mechanism that shipped the NIC-12 batch — ranked last behind everything on this page. Stale figures corrected while we were here: 8 live pages, not nine; the 9,184/0.25% was a windowless 28-day under-read (336-day truth: 192,114 impressions / 451 clicks / 0.235%). Live spot-check 7 Aug: all four URLs still 200, so the work is real — it is just not yours.

Index hygiene — DONE: executed by the fleet 6 Aug, every URL live-verified 7 Aug

✅ DONE · 20 of 20 verified liveNIC-12

We checked before asking — and the work no longer exists. The backlink-checked evidence list (marketing/seo/NIC83_BLOG_HYGIENE_EVIDENCE_2026-08-06.md, all 867 published documents on both sites) named 20 URLs still needing action. The fleet executed the batch under AGA-123 on 6 Aug via the Redirection REST API, and on 7 Aug we re-crawled all 20: 19 return a one-hop 301 to a 200 target — including /blogs//blog/, /home-page-test//, and the missed 25th promo /70-off-new-aug24//start-training/ — and 1 is drafted (404, deliberate). Method note that survives: every retirement was draft + 301, never a delete, because external backlinks are unverifiable from here (no backlink tool in the estate; GSC Links is UI-only) and a 301 preserves unknown equity either way. Residuals, neither yours: (1) /calisthenics-gear/ + /freemonth/ still link the retired /50-off-black-friday/ internally — works via the 301, but the edit is Aga-gated (affiliate ruling); (2) cosmetic draft residue on BWA page 210021 under a live redirect.

Verify the generated password actually logs into the app — 15-minute gate on the welcome email

✓ CLOSED 2 Aug — Aga confirmed the test passed

✓ Done — closed on Aga's confirmation, 2 Aug 2026

The generated password logs into the app; the credentials-box welcome email is cleared to send. Kept below for the record.

The new post-purchase welcome email shows the buyer their username (email) + generated password in a credentials box. Before the first real send we need one test: buy on a test card, take the password from the email, and confirm it logs into the app. Also confirm the %EMAIL%/%PASSWORD% SendGrid substitutions are mapped. The email is rendered for you at tma-paywall-spec.netlify.app/email-welcome-noquiz. Nothing sends until this passes.

A buyer must never receive an abandonment email — removed from your list by Aga, 2 Aug

REMOVED · Aga ruling 2 Aug 2026

Aga took this off the task list on 2 Aug. The substance survives as an acceptance test inside task 4 (the abandonment-lane rebuild): the purchase exclusion is evaluated at send time, a test buyer never receives the abandonment mail, and a genuine abandoner still does. No standalone work item remains.

WP Engine estate — moved under Aga (2 Aug ruling)

MOVED · now AGA-53 · external WordPress dev executes

Aga, 2 Aug: "not for Nic — move it under Aga." The whole workstream (69 outdated plugins + 11 themes triage, the two Lighthouse-48 mobile scores, the bandwidth watch, the deleted themovementdev install) is now AGA-53 on Aga's queue — she coordinates the external WordPress dev directly. The PHP upgrade was already with a separate dev (target 8.2 — Divi 4.27.7 is only tested to 8.2). Nothing here is yours.

Email-source join — folded into task 1 (Aga, 2 Aug)

FOLDED · now change 5 of the buyer-visibility pack

Aga's 2 Aug ruling: this is the same handler work as task 1, so it now lives there as change 5 — write the email source into Stripe at purchase, with the full step-by-step inline (nothing external to open). One correction made in the fold: the old card pointed at Analytics Brief "task 17" for the last-funnel-step export — that export is task 11; task 17 is the canonical-identity keystone. The wrong pointer is gone.

/api/quiz/config 404 — already fixed · verified 200, 2 Aug

no action — the paywall-deploy gate is CLEAR

The revenue plan (task 8) held every quiz-paywall deploy behind this: "the live quiz calls /api/quiz/config, gets a 404, and runs on fallback config — nothing else starts until this reports." Live check 2 Aug: it returns 200 now. Whoever fixed it during the 31 Jul–1 Aug quiz work: thank you. The gate in front of task 6 (the on-page checkout) is open, and the revenue doc's task 8 is stale.

/calisthenics-assessment/ was silently stripping UTMs — found & fixed 2 Aug, no action for you

Code Snippet 9 live · Aga's 2-min rule deletion queued (AGA-54)

What happened: a Redirection-plugin 301 (added after 31 Jul) sent the assessment doorway — YouTube's #1 landing page, 1,991 sessions/28d — to the quiz root with a literal target. WP Engine's edge cache then served that cached bare 301 to every utm-only visitor, so their utm_source/_gl vanished mid-hop and the link-integrity guard regressed 19/0 → 18/1.

The fix, already live: a guarded Code Snippet (id 9, deployed with health-check + auto-rollback, verified 2 Aug) client-redirects the page to quiz.themovementathlete.com/ + location.search — the browser carries the query string, the edge can't strip it. Aga deletes the plugin rule (2 minutes, AGA-54) and the guard returns to 0 dropping. Same funnel behaviour, attribution intact. Filed here so you know why the doorway behaves differently this week.

Redirect pack — done, and it worked

Fix A shipped · verified live 29 Jul

Fix A (quiz root forwards the query string) is live — utm_* and _gl both survive. Guard moved 18/4 → 19/1 → 19/0, exit 0.

The other three were not yours, which is why they are off this list:

  • Fix B /free-front-lever-training-guide/ — we proved WP Engine's edge strips utm_* and _gl before PHP ever runs (control parameters survived the same redirect that dropped the real ones). No WordPress-level fix exists for any 301 on WP Engine. The rule now is simply: link final destinations, never a WP alias. Probe retired, with the reasoning written into the guard so nobody re-opens it.
  • Fix C trailing slashes — WordPress core redirect_canonical(), not nginx. Aga ruled: do not patch core to satisfy a probe.
  • Fix E GA4 domains — already correct.

The /g/ post that 500s — off your list

re-scoped 29 Jul

Only the content field errors; id, status, slug, title and meta all read fine over the API. So the wp-admin list renders and it can be trashed without WP-CLI — no server access needed. The body is unrecoverable via the API (zero revisions); a DB pull is the only route if the story is worth saving. Awaiting Aga's call.

BWA redirect loop — fixed, no action

fixed 29 Jul · verified

A live article was unreachable behind a 51-hop loop on 3,821 hits — two redirect plugins pointing at each other. Fixed at source via the API: the offending rule disabled, the page's slug corrected (it had genuinely been misspelled for years), and a replacement 301 added so the old URL still resolves. …-strength/ now serves 200; …-strenght/ 301s to it in one hop.

llms.txt · pinch-zoom · IndexNow · Organization schema — all shipped

27–29 Jul · both sites verified

Also live since this page was last updated: FAQ structured data on 9 pages (the estate had none), IndexNow active on both sites, and BWA's Organization schema corrected — it had been telling every AI engine our organisation was "Calisthenics Academy".

Stop creating a Stripe record on page load — SHIPPED 3 Aug 2026

DONE · verified live 3 Aug against the served file quiz checkout · stripe-client.js NIC-41

✅ Shipped — and here is exactly what we checked, so you know the standard

Nic — nothing left to do on this card. We did not take the ship on trust: we curled https://app.themovementathlete.com/static/quiz_checkout/stripe-client.js (HTTP 200, 17,446 bytes) and read it. maybePrefetch: 0 occurrences. DOMContentLoaded: 0 occurrences. stampPaywallView: 3 occurrences. cancel-incomplete: present, called as cancelIncomplete(oldSub) on plan switch. Your own comment at line 497 reads "Mint only when the buyer engages: password blur, tap-to-load card form, or pay."

You got the hard part right. The trap on this task was deleting the mint and the signal together — that would have fixed the number and silently killed the recovery lane in the same commit. You kept stampPaywallView, and you cancelled the prior incomplete sub on plan switch, so the orphan-remint half is closed too. Tests logged: QC-INTENT · card 4242 success in Chrome and Safari · login with the checkout password · API plan amounts + force_expired · Google Pay and PayPal Express.

🔴 One consequence everyone must know before reading a dashboard

A measurement discontinuity landed on 3 Aug 2026. Do not compare across it. Phantom trial and subscription counts will collapse, and checkout conversion rate will jumpboth by design, neither is churn or a demand change. The pre-3-Aug conversion rate was fiction and is not a baseline. Clean data starts 4 Aug; first honest read ~11 Aug, reliable ~17 Aug. The quiz abandoner audience also shrinks by design and becomes higher-intent — judge that lane on recovered purchases, never on list size.

The original task is kept below for the record.

What it was, in plain terms: in static/quiz_checkout/stripe-client.js, maybePrefetch() runs on DOMContentLoaded and calls ensureIntent() whenever the email field is pre-filled — and it is always pre-filled, because it comes across from the quiz. So Stripe creates a customer, a subscription and an open $97 invoice for every page view, before the visitor has touched anything.

Why it matters
  1. Checkout conversion is unmeasurable. requires_payment_method and "no payment method attached" are the mechanical default for every non-buyer — someone who bounced in two seconds is indistinguishable from someone who read the whole page and decided no. We spent a day reaching a wrong conclusion because of this.
  2. The recovery lane is counting bounces as abandoners, so the abandoned-cart audience is inflated with people who never intended to buy.
  3. Stripe fills with phantom subscriptions — 48 records for 41 distinct people in the first four days.
How
  1. Move the ensureIntent() call out of maybePrefetch() / DOMContentLoaded.
  2. Fire it on the first interaction with a card field — focus, or first keystroke on the card number. Keep the existing password-blur trigger as it is.
  3. Do not create an intent on load under any condition.
  4. 🔴 KEEP stampPaywallView — do not delete it along with the mint. Corrected 3 Aug by the code audit: the signal you need already exists, so this is now a "leave it alone" instruction, not a build. stamp-paywall-view already records email · plan · path · pending password into Redis/cache and queues through quiz_checkout_abandon.py. When you move ensureIntent(), stampPaywallView must still fire on load — it is what keeps the recovery lane fed once the Stripe phantom is gone. No new endpoint needed. Why this half is not optional: checked live on 3 Aug across all 44 abandoner subscriptions — every invoice reads attempted=false, attempt_count=0, and not one carries a payment_method or a last_payment_error. Nobody has ever typed a card into this checkout. So the phantom intent is currently the only record anywhere that a person reached the checkout and left. The moment "no trace in Stripe" is true and stampPaywallView has been removed with it, the recovery lane loses its entire audience feed, silently — we would fix the number and kill the lane in the same commit.
  5. Also avoid orphan remints. The quiz plan-switch currently remints client-side without cancelling the prior incomplete subscription. Whatever you do to the load-time mint, make the switch cancel the one it replaces — otherwise the debris just moves rather than stopping.
  6. Test (budget 15m of the 60m): load the checkout with a pre-filled email and do not touch the card form → confirm no new Stripe customer or subscription appears, and that the stamp-paywall-view record did arrive with plan/path intact. Then type one character into the card number field → confirm the intent is created and a real purchase still completes end to end. Finally switch plans twice → confirm only one incomplete subscription exists, not three.

The same constraint bites on checkouts-v3 — read before task 1, change 1

v3 has the identical trap in mirror image. There, typed-email abandoners are captured by stamp-abandon-email, which needs an intentId to stamp onto. So if v3's create-intent is deferred to pay-click, that capture has nothing to write to and abandon recovery goes blind on that estate. Two acceptable answers: mint at email-blur rather than page load (early enough to stamp, late enough to mean something), or give v3 a Redis-style stamp like the quiz has. Either works — silently doing neither is the failure mode.

Done when

A page load with zero card interaction leaves no trace in Stripe but does leave a stamp-paywall-view record, and a real purchase still works. From that day, checkout conversion becomes a real number and the recovery lane keeps a real audience.

2

The 48-hour $97 window — moved to Aga, do not build

MOVED · now AGA-68 probed live 3 Aug on the served page nothing for you here

Nic — nothing to do here. This came off your list on 3 Aug. Whether the deadline should truly bind is a pricing decision (binding buys honest urgency, but the person who returns on day 5 ready to pay $97 would meet $157 and may buy nothing). Aga rules first. The findings below are kept so whoever picks it up does not have to re-probe.

What is already right: the per-visitor deadline is fixed once set — the same address returns the identical timestamp on repeated requests, so the clock does not silently restart on every visit. That part works.

The two holes
  1. An address the server has never seen gets a fresh 48 hours. Verified with a never-seen address: exactly 48.0h. So the offer is re-obtainable with a new email — and an existing person whose cache entry has lapsed gets a brand-new window. Live example: wajackson1@gmail.com abandoned on 30 Jul and today holds a window running to 4 Aug 08:53.
  2. We have never observed the expired state. TMA_QC_OFFER_EXPIRED came back false on every address tested, including a 90-hour-old abandoner — so we cannot confirm the $97 is actually killed at charge time, only in the UI. Your own comment in the page source says it plainly: "the same deadline must kill the $97 server-side (coupon dies → $157). A timer that changes nothing is banned."
How
  1. Once an email has had a window, never mint it a second one. A lapsed cache entry must resolve to EXPIRED, not to a fresh 48h.
  2. Enforce it at charge time, not just in the interface.
Done when

Take an address whose window has passed and load the checkout → TMA_QC_OFFER_EXPIRED is true and the page shows $157. Attempt the purchase → Stripe charges $157, not $97.

Why it matters to us: emails 3 and 4 of the recovery sequence tell people their window is closing, and then that it closed. If the page hands them a fresh 48 hours on arrival, we have lied to them in writing — and taught them that every deadline we ever set is theatre.

6

Wrong star rating and a contradicted member count, both in the live quiz bundle

found 3 Aug by the fact-checker ~20m — batch it with task 3 2026-quiz-checkout-v1 source → rebuild NIC-46

Batch this with task 3. It is the same bundle and the same redeploy, so doing them together costs almost nothing extra — and doing them apart costs a second deploy.

What is wrong
  1. Two "4.9" star strings4.9 · 100,000+ plans created and ★★★★★ 4.9 · 100k+ users. The settled ruling (31 Jul) is to ship 4.7 unattributed. Measured live 3 Aug: iTunes API 4.53 across 297 ratings; the Google Play listing reads "Rated 4.7 stars". 4.9 overstates both.
  2. One "Join 53,000+ people" sitting on the same funnel as six "100,000+" strings. Whichever is right, they cannot both be — a visitor who notices stops believing either.
  3. Also re-export Step-1-Take-Assessment-1.png — the image bakes in "It only takes 5 minutes" against Aga's 3-minute ruling, so it cannot be fixed in copy.
Done when

A grep of the redeployed bundle returns zero "4.9" star strings and zero "53,000+", and the assessment image no longer says 5 minutes. 🔴 Do not ship 4.8 anywhere — that claim is governed separately by AGA-84 and needs Aga's written confirmation.

3

Quiz answers into the recovery emails — FOLDED into NIC-54, 6 Aug

FOLDED into NIC-54 — one task, one sitting no separate work NIC-45

✅ 6 Aug — merged into NIC-54, and three checks made it smaller

Aga's call: everything you send us about a lead is one task — the five stage tags and these attributes hit the same helper (add_tag_to_quiz_contact) and the same /api/quiz/quiz_lead handler. Splitting them would have you opening the same files twice.

We read your live bundle before writing the task, and each finding shrinks it:

  • obstacle is ALREADY sent. PM(e,t) posts {name, email, phone, primary_path, obstacle, quiz_variant, source} today. It was dying because ActiveCampaign had no field to receive it — not a quiz fault.
  • all_mastered is ALREADY computedallMastered = fundamentals.every(m => progress === 4) on the results context, and ALL_MASTERED is also an 8th PRIMARY_PATH value. One more field on the payload, not a calculation to build.
  • age and gender already leave the quiz — but on rR(e), the app's plan-provisioning call, not the lead call.

✅ We already did our half so you are not blocked — the five receiving AC custom fields were created via the API on 6 Aug: [64] TMA Quiz Obstacle · [65] TMA Quiz Future Stakes · [66] TMA Quiz Age · [67] TMA Quiz Gender · [68] TMA Quiz All Mastered.

🔴 The original instruction below is WITHDRAWN — do not build it

It said: append the four to the quiz→checkout hand-off URL so they ride into Stripe metadata. Two reasons that is the wrong place:

1. Stripe metadata only exists if they reach checkout. Everyone who finishes the quiz and stops leaves nothing — and that is precisely the audience a recovery email is for.

2. It hardcodes the destination into the front end. Put the fields on the existing quiz_lead payload instead and Django becomes the fan-out — AC today, AC + Customer.io during the migration, CIO after. You touch the quiz once, ever. (Customer.io cannot send at all today — mail.themovementathlete.com has no MX/SPF/DKIM, verified 6 Aug — so a browser-to-CIO signal would land nowhere.)

For the record — the original card

What travels today (verified live 3 Aug, and re-confirmed against the hand-off code): the quiz → checkout hand-off sends email, path, persona and plan (plus the utm_*/gclid/fbclid it copies off the URL). Stripe subscription metadata carries email, persona, path, plan_slug, plan_key, plan_id, offer, offer_expired, coupon, quiz_variant, screen_id, checkout_version, purchase_path, plus utm_source/medium/campaign on 33 of 48.

What does not travel, and the quiz already knows: OBSTACLE · FUTURE_STAKES (their own words for what they fear if nothing changes) · AGE · GENDER. All four die at the checkout boundary.

How
  1. Append the four to the quiz → checkout hand-off URL exactly as path and persona already travel, and read them the same way.
  2. Include them in the stamp-paywall-view record — and in intent metadata once a card is touched.
  3. No Django schema work and no new tables. Same pattern as the params already flowing.
Done when

Complete the quiz, land on the checkout, and all four appear in the stamp-paywall-view record carrying the values the quiz collected.

Why it matters to us: today a recovery email can say what they wanted. With these it can say what was standing in their way, and what they told us they were afraid of. That is the difference between a template and a letter.

8

One definition of a lead — there are currently three

from the 3 Aug skeptic gate ~1.5h incl. 30m validation scoreboard config NIC-42

What it is, in plain terms: the same metric has three live values on the same day, and the rate built from it has two values 24× apart depending on which denominator you pick.

SourceLeads / 7dDenominator
GA4 ordered funnel65244 quiz-landing visitors → 26.6%
GA4 generate_lead1038,957 property sessions → 1.1%
ActiveCampaign quiz list140

They differ for real reasons — event scope, hostname filter, AC list membership and dedup. The problem is not that they differ, it is that nothing declares which one is "the" number. Two of the four sourcing errors in the 3 Aug quiz analysis trace directly back to this.

How
  1. Reconcile the three counts and establish why each differs.
  2. Publish one canonical numerator + denominator pair as the definition of "quiz lead" and "quiz lead rate" — written into the scoreboard config, not into a doc where it can drift.
  3. Keep every other counter visible, but label each explicitly as a different measure. Never mix them. Follow the precedent already set for app sign-ups: RevenueCat customers_new and GA4 onboarding are different counters, both real, never mixed.
  4. Test (budget 30m of the 1.5h): kpi_15k.json and data_funnel.json report the same lead count and the same lead rate for the same window, and a third party can reproduce both from the stated query without asking which source to use.
Done when

One definition lives in config, the two scoreboard files agree, and the other counters carry an explicit label saying what they measure instead.

Pre-clean the re-engagement backlog before the feed continues

MOVED · now AGA-63 · Aga is handling this

What it is: the R1 canary on 991 NEW Re-Engagement - For people who stopped opening hard-bounced 6.9% (33 of 478) — 3.5× the 2% hard-stop line.

How
  1. Pre-clean the ~9,128-contact backlog: drop known-dead and bounce-history addresses.
  2. Do this before enter_991_reengagement.py continues the 2k/day feed.
Done when

The backlog is cleaned and the feed resumes under the 2% line. Skipping this means domain reputation pays for hygiene we would otherwise get free from the suppression step.

Activate the 3 pre-registered A/B splits in ActiveCampaign

MOVED · now AGA-64 · Aga is handling this

What it is: three A/B splits are pre-registered and waiting — the 750 TMA | Welcome Sequence After Quiz E1 subject test, plus nurture and re-engage. They need manual setup in ActiveCampaign before they can run.

How
  1. Build the three splits in AC. Setup specs are already written — EXPERIMENT_LEDGER.json W29 records and WEEKLY_2026-08-03.md §5.
Done when

All three are live. This is the third consecutive week the self-learning loop has run with zero live experiments, so it is currently learning nothing.

Make spam complaints visible — the #1 hard-stop is currently blind

MOVED · now AGA-65 · Aga is handling this

What it is: the weekly safety sweep pauses all sending at 0.10% spam complaints. That is its number-one hard stop — and complaint counts have been DARK since the loop was built, on the platform carrying roughly 100% of send volume. We cannot currently trigger the most important safety rule we have.

How
  1. Extend tools/email-analytics/collect_metrics.py to pull ActiveCampaign spam-complaint counts.
  2. Feed them into the safety sweep so the 0.10% hard-stop can actually fire.
Done when

Complaint rate appears in the weekly metrics pull and the hard-stop is armed. Flagged 27 Jul, still open.

The attribution stamps — SHIPPED 7 Aug 2026; all 3 now CONFIRMED on the quiz rail (10 Aug)

✅ SHIPPED page + source writes CONFIRMED in served code, 7 Aug 2026, 19:24–19:28 UTC ✅ PaymentIntent stamp CONFIRMED 10 Aug — 6 of 6 quiz sales legacy /register/ PIs still bare — FLEET-45 NIC-91 + Task 30

✅ Shipped — and as of 10 Aug all three are confirmed on the quiz rail

Nothing left for you on this card. Verified 7 Aug 2026, 19:24–19:28 UTC against served code, not against the message:

(1) The page — CONFIRMED. /register/monthly_2026/ now serves <input type="hidden" name="quiz_variant" id="quiz-variant-input"> into process_stripe, alongside the full utm_* set plus gclid, fbclid and pe. The page JS backfills the variant from ?quiz_variant= or ?utm_content=, so it survives a link that only carries the UTM.

(3) The source — CONFIRMED. The live bundle index-yC00xO94.js at /2026-quiz-checkout-v1/ defines first_touch_utm_source, _medium, _campaign, _content, _term, plus first_touch_fbclid, first_touch_fbc and first_touch_fbp, gated by a once-only __tma_quiz_first_touch_applied__ flag. That flag is the part that matters — it is what makes it first-touch rather than a re-read of whatever URL the visitor is on now.

(2) The checkout system — NOT YET MEASURABLECONFIRMED 10 Aug, see the green box below. The original 7 Aug reasoning is kept as written: The checkout_version copy onto the PaymentIntent happens in Django, which we cannot read from outside. It can only be proven by a sale created after your deploy. There was none: the last PaymentIntent of the day was 11:31 UTC and you shipped around 19:20 UTC. Reading those empty PIs as a failure would be a false negative — they were minted eight hours before the code existed.

So we built the check instead of leaving it to memory. python3 tools/analytics/attribution_stamp_check.py filters to your deploy time and reports one of three states: confirmed, still-no-sale, or genuinely missing. On 7 Aug it returned still-no-sale.

✅ ANSWERED 10 Aug 2026 — run again on 18 real sales

Verdict: PARTIAL — 6 stamped, 12 not. And the split is not random, it is one rail versus the other.

✅ The quiz rail: 6 of 6 stamped. Your write works. Every completed sale through /payments/quiz-checkout/ since your deploy carries, on the PaymentIntent, checkout_version=quiz_checkout, quiz_variant=quiz_checkout_2026_v1 and the full utm_* set — including gclid where the visitor arrived from Google. That is the write we could not prove on 7 Aug, and it is now proven on real money. It is also doing real work: it is the only reason we could answer Aga’s question of 10 Aug about which sales paid ads actually created.

⚠️ The legacy /register/ rail: 0 of 12 stamped. Those PaymentIntents ($9.99, $19.97, $24.97, $49.97, $132.13, $157…) carry no checkout_version, no quiz_variant and no UTMs at all. So the "we cannot split the legacy money by funnel" problem below is still true.

One genuinely new thing, and it is yours: on 10 Aug a subscription appeared carrying checkout_version=register — the first ever. So the page write from (1) is now firing in production, not just present in the served HTML. It still carries no UTMs and no quiz-build name, so it does not yet close the attribution gap. No action requested — recorded so the next person does not re-open a question that now has an answer.

Everything below this line is the original card, kept so the diagnosis and the reasoning stay on the record.

🔴 The problem, in one line

Three quiz funnels are live at the same time and the money cannot be split between them. Re-verified 7 Aug PM from the four live bundles + a fresh Stripe pull: every build links stampless /register/ checkout URLs, and all 54 subscriptions on legacy prices since 1 Jul carry EMPTY metadata — 54 of 54. Nothing in Stripe names the page a legacy sale came from. So when we ask "which build converts better, and at what AOV" the honest answer today is we cannot know — not "we estimate", cannot. (One correction to our own earlier evidence line: the bundles' /register/ link sets are no longer identical — v1's rebuilt bundle now leans yearly_2026 ×8, v2-2 and /2026/ lean yearly3 ×7, and 2026-v2-3 links the older monthly2/monthly3/quarterly2/yearly3 set. The conclusion is unchanged: none of them carry a variant.)

And here is what re-verification found that makes this cheaper than we thought: every build already knows its own name. Read off the served bundles today: VITE_QUIZ_VARIANT is baked in at build time — paywall_v2_2 · control (the /2026/ build) · paywall_v2_3 · quiz_checkout_2026_v1 — and the shared event helper already attaches it to every GA4/Meta event. The name exists client-side in all four builds. It just never reaches Stripe on the legacy rail.

Do this

(a) Get the build name onto the legacy sale — two halves, both needed. The client half: where the 3 old builds compose their /register/… links (shared code — one change, rebuilt into three bundles), append the value the bundle already holds, e.g. ?quiz_variant=paywall_v2_2. The server half — and without this the client line does nothing: the /register/ Django flow currently writes zero metadata (54 of 54 legacy subs empty), so it must read that param and write it into the subscription's Stripe metadata at create, the same key v1 uses (quiz_variant). Same field, same shape, so one query reads all four.

(b) v1 — first-touch rehydration, precisely scoped. Correcting our own earlier line here: a fresh Stripe pull shows 5 of 8 v1 buyers DO carry a utm_source on the subscription (google ×1 with gclid — the only external paid source · tma ×3 internal · bwa ×1 our blog); 3 of 8 are bare. The handoff already forwards the live query string — what is missing is the buyer who lands bare (returns later, direct, internal nav). Fix: persist first-touch utm_*/gclid/fbclid on first landing (localStorage; the Amplitude SDK in the bundle already computes first_touch_*, so the values exist) and have the checkout handoff append them whenever the current query string has none. Keep writing them where you write them today — on the subscription; on this rail the PI carries no UTMs and nothing needs to move.

(c) The checkout system — folded in from the estate-stamp card, same sitting

The quiz rail's subscriptions are already stamped and your client already sends checkout_version:'quiz_checkout' in the create body — but the PaymentIntent drops it (8/8 succeeded quiz PIs since 1 Aug bare, each join-proven quiz-rail; the PI is what your server CAPI keys on) and the client purchase events carry no estate stamp (0 hits in all four bundles). Two writes: copy the field you already receive onto the PI at create-intent, and send checkout_version on the purchase-side events alongside the quiz_variant they already carry. 🔴 This half must land before the ~11 Aug honest read. Full evidence, numbers and acceptance checks: the estate-stamp card below — kept intact because the analytics brief links it.

Done when — one validation pass covers all three

A test sale from each of the four builds carries its build name in subscription metadata (quiz_variant) · the newest quiz-rail PI carries metadata.checkout_version=quiz_checkout and the served bundle greps >0 for checkout_version · a bare-landing test purchase on v1 (land with UTMs → navigate → return with none → buy) still carries its first-touch utm_source. One query then separates the funnels, the estates and the sources. Tell us the field names you used and we will point every report at them the same day.

What we tried first, so you are not repeating it

We grepped all four live bundles twice (7 Aug AM + PM — the PM pass found the link sets have diverged but are equally stampless), read Stripe metadata (54/54 legacy subs empty, re-pulled 7 Aug 15:33 UTC), and ran a $157 price-fingerprint test to infer the split — it failed: sales are decoupled from page traffic (Facebook says 5, Stripe says 19). Attribution needs a write in the checkout handoff + /register/ code, which is in repos we cannot write. Evidence: tma-growth-cockpit-2026.netlify.app/quiz-funnel-versions/ §1 + §18.

The T+1h abandon email — ANSWERED 7 Aug 2026

ANSWERED · acted on the same day Django→SendGrid NIC-90

The question — two lines closes it

(a) Is the ≈T+1h "Your login is in this email" Django→SendGrid send live in production right now? (b) What is the trigger condition and delay, as the code has it today?

🔴 Do not change anything. This is a read-and-report.

Why it matters, and why now

756 Part 1 - Abandoned Cart Reminder is about to be loaded with two recovery emails and armed. Its supply is our abandon sync, which found 43 abandoners worth $4,171 in 30 days. If your T+1h send is live, the same person gets your email at ~1h AND our E1 on the next sync — two recovery emails from two systems that cannot see each other, to someone who just declined a price.

Your answer changes ours, both ways: if it is live we delay E1 past it or drop the overlap; if it is not, we send E1 immediately on entry. Either is fine — guessing is not.

Why we cannot answer this ourselves — we tried three ways

(1) Our Django clone is 5b54ac7e, dated 2 Apr 2026, and predates the machinery entirely — grepping paywall_view/abandon across app/**/*.py returns zero hits, so it cannot show a send built after April. (2) No SendGrid credential exists anywhere in our estate — we searched .env.local and the whole repo for the SG. key pattern. (3) The AC API cannot see it, because it is a Django→SendGrid send outside ActiveCampaign altogether. The fact lives only in the production repo or the SendGrid console. One caveat on our own record: our email master says this send is live, but that came from a log reading, not from the code — which is exactly why we are asking rather than asserting.

checkouts-v3 phantom mint — CLOSED 7 Aug 2026: the mint is fixed AND all 5 stranded subs are cancelled

✅ DONE · both halves all 5 ids re-fetched individually from Stripe, 7 Aug 2026, 19:24–19:28 UTC NIC-92

✅ Closed — both halves; all five subscription ids re-fetched from Stripe

Nothing left to do on this card. On 7 Aug 2026, 19:24–19:28 UTC we fetched each of the five subscriptions individually by id — not a list query, so there is no chance of a filter hiding one. All five return status=canceled, canceled_at 2026-08-07 18:58 UTC, cancellation_details.reason=cancellation_requested. The code half was already closed on 5 Aug and remains so. There are no active unbillable v3 subscriptions left from that window.

Everything below this line is the original card, kept so the diagnosis and the reasoning stay on the record.

✅ Finding 1 is RESOLVED — your defer-mint stopped the phantom mint, and we can see it in the daily counts

The 2.2×/visitor figure came from a window (1 Jul–6 Aug) that is mostly before your NIC-19 defer-mint. Re-pulled 7 Aug 15:35 UTC, v3-stamped mints per day: Jul mean 3.0/day with spikes of 21 and 29 · Aug 1st=1 · 2nd=3 · 3rd=9 · 4th=8 (all eight incomplete_expired ghosts) · 5th=0 · 6th=2 · 7th=0. The two 6 Aug mints are both trialing — real checkout starts. Zero phantom mints since 5 Aug. A visitor who lands and does nothing now leaves no Stripe record — the "done when" on the code half is met, and no further code work is being asked for on this card.

🔴 Finding 2 still stands and it is money — five ACTIVE subscriptions we cannot charge

Re-verified individually 7 Aug 15:35 UTC — all five still active right now, each with no payment method, no paid invoice above $0, no discount, no trial end, all on ad-hoc $24.97/mo prices, minted 27–28 Jul (pre-fix): sub_1TxtC5I6s6pBAbnmWhvmZm6g · sub_1TyF1XI6s6pBAbnmRUud2JzC · sub_1TyF2NI6s6pBAbnmjEUjUbjn · sub_1TyF6dI6s6pBAbnm7jiTRl8z · sub_1TyIEBI6s6pBAbnmehkuB2MI. Sharper than first written: these five are ALL of the currently-active v3 subs from the window — 5 of 5 active are unbillable (3 more sit trialing, a different and billable state). They need a billing call — bill them, or end them, but they cannot stay as they are.

The shape of the real funnel, for context

LayerCountWhat it tells us
GA4 visitors53the true denominator
Stripe subscriptions minted1172.2× the visitors — the defect
Cards entered4real purchase intent
Payments1the actual sale
Cancellations58all cancellation_requested (API-initiated); zero payment failures — so this is not churn, it is cleanup of records that should never have existed

Every conversion rate computed off the minted number is wrong by 2.2×, in the flattering direction. (This table describes the mostly pre-fix window — kept so nobody re-reads the era's numbers as current. The v3 stamp first appears 17 Jul, so the window effectively starts there.)

Done when

The 5 unbillable actives are resolved one way or the other — that is the whole remaining task. The other half ("a visitor who does nothing leaves no Stripe record") is verified met since 5 Aug; we re-run the daily-mint count after any v3 deploy. Per-layer diagnosis: tma-growth-cockpit-2026.netlify.app/quiz-funnel-versions/ §13 Step 3.

Noindex the non-production hosts — CLOSED 7 Aug 2026: all three hosts serve the header

✅ DONE 3 hosts + 4 app routes curled 7 Aug 2026, 19:24–19:28 UTC NIC-43

✅ Closed — all three hosts serve the header, and we re-checked the app routes too

Nothing left to do on this card. curl -I on 7 Aug 2026, 19:24–19:28 UTC:

quiz-test. → 200 with X-Robots-Tag: noindex, nofollow · monitor. → 302 to /login with the header · grafana-dev. → 302 to /login with the header. The security item on grafana-dev is closed along with the SEO one.

We also re-checked the Django half rather than assuming it still held: /login, /register/monthly_2026/, /payments/quiz-checkout/ and /payments/checkouts-v3/trial/1month/ all return 200 with the header, and app-test. inherits it. staging. and beta. are Cloudflare and remain Aga’s under AGA-67 — they were never on your list.

Everything below this line is the original card, kept so the diagnosis and the reasoning stay on the record.

✅ 6 Aug — the half that mattered is shipped, and we verified all four routes

curl -I against production returns X-Robots-Tag: noindex, nofollow on:

app.themovementathlete.com/login                      X-Robots-Tag: noindex, nofollow
app.themovementathlete.com/register/*                 X-Robots-Tag: noindex, nofollow
app.themovementathlete.com/payments/quiz-checkout/    X-Robots-Tag: noindex, nofollow
app.themovementathlete.com/payments/checkouts-v3/*    X-Robots-Tag: noindex, nofollow

That was the pricing-exposure half, and it is closed. app-test. inherits the same Django headers, so that host is covered too. Three hosts remain, and they are all server-level rather than Django — which is why they did not come along for the ride:

HostChecked live 5 AugStill needs
quiz-test.200, no headernginx add_header
monitor.302, no headernginx add_header — its Disallow: / does not de-index
grafana-dev.302 → /login, no header🔴 nginx header and a look at why it is publicly reachable
staging. · beta.200, no headerNot yours — Cloudflare, Aga (AGA-67)

Context, because it changes the fix: the /register/ promo URLs are intentional unlisted-URL offers — public by design, reached only by direct link. Aga is keeping them. So the job is not to redirect or remove anything. It is to make them reachable by link and invisible in search, which is exactly what noindex does.

Verified live 3 Aug. app.'s robots.txt disallows only /survey, /skills/, /workouts/, /achievements//register/ and /login are NOT blocked, so Google can crawl them and will see a noindex on the next crawl. That is the good case; no robots.txt surgery needed.

HostLiverobots.txtServed by
app.200real, 4 rulesnginx
app-test.302 → 200same as app.nginx
quiz-test.200none (SPA returns HTML)nginx
grafana-dev.200 → /loginsame as app.nginx
monitor.200 → /loginDisallow: /nginx
staging.200nonecloudflare → Aga (AGA-67)
beta.200nonecloudflare → Aga (AGA-67)
How — your half (the nginx hosts)
  1. Send X-Robots-Tag: noindex, nofollow on app./register/* (blanket). This also covers the live main routes /register/monthly_2026/ and /register/yearly3/that is intended: register and checkout pages should never rank.
  2. Same header on app./login — an indexed /login URL is carrying a full GA linker blob (_gl / _ga / _ga_ZBQN3P8WKW, 71 impressions). Tracking parameters should never be crawlable.
  3. Same header on app-test., quiz-test. and grafana-dev.. 🔴 grafana-dev. is a monitoring dashboard, publicly reachable, and was missing from the original scope — treat that as a security item, not an SEO one. quiz-test. was also missing.
  4. monitor. already serves Disallow: /, but a Disallow does not de-index — it needs the header too.
  5. Basic auth on the non-production hosts is the stronger fix — it de-indexes and stops anyone reaching them. Flag it if it would break a tester or CI workflow before applying.
🔴 The one ordering rule

noindex FIRST. Never add a robots.txt Disallow first. Blocking the crawl stops Google ever seeing the noindex, which freezes the URLs in the index as bare links — the opposite of the goal. Let them be crawled, see the noindex, and drop out. Only then consider disallowing.

No effect on the offers. Anyone with a direct link still reaches these pages and can still buy. noindex only removes them from search results.

Done when

curl -I on each host and route returns X-Robots-Tag: noindex, and the hosts plus the two promo URLs fall out of the TMA Search Console property within about two weeks.

Fix the TMA SPF record — SHIPPED & verified live 5 Aug

DONE 5 Aug 2026 · verified at the authoritative NS SCOPE: themovementathlete.com ONLY ~15m + 20m validation DNS zone edit — no code NIC-79

✅ 6 Aug — CLOSED, and we checked it the right way

Verified with dig +short @ns1.siteground.net themovementathlete.com TXTthe authoritative nameserver, not a public resolver. That distinction earned its keep here: a public-resolver answer showed no SPF at all while the zone was serving one. The record now reads:

v=spf1 a mx ip4:35.214.157.236 include:spf.tapfiliate.com
       include:themovementathlete.com.spf.auto.dnssmarthost.net
       include:_spf.google.com ~all

The dead include:spf.acemsb4.com is gone and include:_spf.google.com is present — the two things that mattered. You kept mx where we suggested dropping it, and that is fine — we are not asking you to touch it again. It costs one DNS lookup and the record still sits inside the RFC 7208 limit of ten. Your SiteGround screenshot is on Slack. The original diagnosis is kept below for the record only — no action in it.

📋 For the record — the fault this fixed

Pulled from a real Authentication-Results header on a message in the hello@ mailbox, dated 4 Aug 2026:

spf=permerror (google.com: permanent error in processing)

DMARC is currently passing only because the Google Workspace DKIM key (google._domainkey) is signing and aligned. There is no second line of defence. If that key is ever rotated badly — or the mail is forwarded, which breaks DKIM — p=reject means the message is rejected, not junked. Silent and unrecoverable.

The two faults

1. include:spf.acemsb4.com does not exist. It is NXDOMAIN. Under RFC 7208 §5.2, an include: whose target has no SPF record returns permerror — and because SPF mechanisms evaluate left to right and stop at the first match, anything not matched earlier falls into it and never reaches ~all.

2. The one that actually matters: there is no include:_spf.google.com. The record leans on mx to cover Google, and that does not work:

# what "mx" authorises — Google's INBOUND servers
aspmx.l.google.com      172.253.145.26
alt1.aspmx.l.google.com 173.194.42.27
alt2.aspmx.l.google.com 142.251.96.27

# where Workspace actually SENDS from (_spf.google.com)
ip4:74.125.0.0/16   ip4:209.85.128.0/17

# overlap: none. So every hello@ / hi@ / nic@ send walks the whole
# record and lands in the dead include above.

✅ Do NOT touch ActiveCampaign or SendGrid — both are correct

Both send with their own envelope subdomains, each carrying a valid SPF record and aligned DKIM. Verified dmarc=pass in real headers. The acemsb4 include is vestigial — AC never consults it, so deleting it changes nothing for them.
• ActiveCampaign → em-152341.themovementathlete.com, DKIM acdkim1
• SendGrid → em9638.themovementathlete.com, DKIM s1

Step 1 — replace the root SPF record

TMA's zone is authoritative at ns1/ns2.siteground.net, so this is the SiteGround DNS zone editor. If the Cloudflare migration has already moved the zone by the time you do this, do it there — the values are identical.

Edit the TXT record on themovementathlete.com whose value starts v=spf1. Leave every other TXT record alone (the google-site-verification and _globalsign ones must stay).

FROM:

v=spf1 a mx ip4:35.214.157.236 include:spf.tapfiliate.com include:themovementathlete.com.spf.auto.dnssmarthost.net include:spf.acemsb4.com ~all

TO — copy this exactly:

v=spf1 a ip4:35.214.157.236 include:_spf.google.com include:spf.tapfiliate.com include:themovementathlete.com.spf.auto.dnssmarthost.net ~all

Three changes: the dead include:spf.acemsb4.com is removed, include:_spf.google.com is added, and mx is dropped (it only authorised Google's inbound relays, which never send our mail — dropping it buys back a DNS lookup). Keep ~all, do not change it to -all this pass.

Measured lookup budget after this change: 7 of the 10 RFC 7208 allows.

Step 2 — fix the malformed record on the app subdomain

The TXT record on app.themovementathlete.com is missing a space before ~all, which makes it an include: of a domain that does not exist — a second permerror — and leaves the record with no terminal all at all:

# current — note "themovementathlete.com~all" is being read as one hostname
v=spf1 +a +mx +ip4:35.214.157.236 +a:spf.tapfiliate.com +a:tapfiliate.com +mx:spf.tapfiliate.com +include:spf.tapfiliate.com +include:themovementathlete.com~all

Replace the whole value with:

v=spf1 include:_spf.google.com include:spf.tapfiliate.com ~all

The +a: / +mx: mechanisms pointed at Tapfiliate were burning DNS lookups to authorise hosts that never send as app.. This takes it from 9 lookups saying nothing to 3 saying something true.

Deliberately NOT in this task

DMARC is currently p=reject; sp=none. The sp=none leaves every subdomain with no policy and should eventually be tightened — but not in the same change as the SPF fix. If anything regresses we need to know which edit caused it. We will raise sp= as a separate task after a fortnight of clean reports. Do not change the DMARC record.

Step 3 — verify (do not skip; this is the part that proves it)

Wait for the TTL to expire, then confirm the record is actually being served and that the chain resolves clean:

# 1. the new record is live
dig +short TXT themovementathlete.com @1.1.1.1 | grep spf1

# 2. the dead include is gone and Google is present
dig +short TXT themovementathlete.com @1.1.1.1 | grep -c acemsb4      # must be 0
dig +short TXT themovementathlete.com @1.1.1.1 | grep -c _spf.google  # must be 1

# 3. the app subdomain
dig +short TXT app.themovementathlete.com @1.1.1.1

Then the real test — send one email from hello@themovementathlete.com to a Gmail address you control. Open it, Show original, and confirm the header now reads:

spf=pass ... smtp.mailfrom=themovementathlete.com
dmarc=pass (p=REJECT ...)

Send us that header block and the task closes. Until an spf=pass is seen in a real message, this is not done — a correct-looking record is not the same as a passing one.

Timeline — implementation + validation

~15 min to make the two record edits, +20 min to wait out the TTL, validate the values against the live zone, run the checks and send the test message. ≈35 min total. If the SiteGround panel disagrees with anything written above, trust the panel and tell us — these values were derived by resolving the live DNS from outside, not from inside your control panel.

Stamp checkout_version on the quiz rail’s payment intent and client events — folded into the attribution-stamps card (Aga, 7 Aug)

FOLDED · write (c) of the attribution stamps — one sitting, one validation pass Django create-intent + quiz bundle events — no release Task 30 on the analytics brief

✅ What is already working — re-measured 7 Aug from 1 Aug only, on Aga’s instruction

The new checkouts released Friday 31 Jul, so the window is 1 Aug onward — 75 subscriptions, pulled exhaustively (paginated, not a top-100). Your subscription-level stamp works on both estates: 50 carry checkout_version=quiz_checkout (with quiz_variant=quiz_checkout_2026_v1) and 23 carry checkout_version=v3. The remaining 2 are not checkout sales at all — see the resolved note below. So at the subscription level, “which checkout produced this money” is answerable in Stripe today. (Denominator honesty, from the 7 Aug PM re-pull: 53 of the 75 are incomplete_expired page-load ghosts — the real-activity split is 22: quiz_checkout 9 · v3 11 · dj-stripe 2. The by-estate conclusion is identical on either basis.) This card is about the two layers that still miss the stamp — not a claim that nothing works.

🔴 Gap 1 — the PaymentIntent never gets the stamp (the subscription does)

Re-verified 7 Aug, 1 Aug onward, exhaustively (102 PIs): 8 “Subscription creation” PIs succeeded — 7 at $97, 1 at $12.48 — and not one carries checkout_version (metadata empty or just password_set_at_checkout). And we closed the loop this time: we walked each of the 8 through invoice → subscription, and every linked subscription IS stamped quiz_checkout — so all 8 are proven quiz-rail sales whose PI alone is blind. checkouts-v3 stamps the PI at create — its live stripe-client.js sends {checkout_version:'v3', plan, attribution} in the create body, and 12 v3-stamped PIs sit in the same window (precision so nobody misreads them: all 12 are canceled $0 abandoned mints, zero collected — they prove the v3 write works, not that v3 sold 12). And the second verification pass found the good news: your quiz client already sends the field too. The live /static/quiz_checkout/stripe-client.js create body reads {plan_key, email, attribution: qsAttr(), checkout_version: 'quiz_checkout'} — the value arrives at the server on every mint and lands on the subscription but not the PI. So Gap 1 is a pure server-side copy of a field you already receive.

Why the PI matters even though the sub is stamped: the PI is the object your server CAPI keys on (event_id = PI id at /payments/analytics/stripe-purchase/), the object refunds and disputes reference, and the object any PI-level export reads. Today every one of those needs a join through invoice→subscription to learn the estate; one metadata write at create-intent removes the join.

🔴 Gap 2 — the client events: 0 hits in all four live quiz bundles

Re-pulled 7 Aug: checkout_version appears 0 times in the served bundles of /2026-quiz-checkout-v1/ (index-yC00xO94.js — note it was rebuilt again since 6 Aug’s index-DFwXN-qe.js), /2026-v2-3/, /2026/ and /2026-v2-2/. The funnel events do carry quiz_variant (screen_view/page_view/fbq — good), but the purchase-side events carry no estate stamp, so GA4 and Meta can only split a sale by URL heuristics — which break every time a path changes, which is exactly what the last week was.

The consequence with a date on it: the ~11 Aug honest read of the $97 paywall needs per-estate RPV (the <$6.04 kill-switch). Without the stamp that number is a hand-built join instead of one GA4 filter.

✅ RESOLVED — the “unexplained subs” question is withdrawn, no action

An earlier draft of this card asked you to name the path behind 4 metadata-less subs. Aga corrected the window — the new checkouts released Fri 31 Jul, so the 2× Quarterly V2 (31 Jul) are pre-release and don’t count. We traced the remaining 2 ourselves (Monthly V3, 2 + 6 Aug): both minted by Django/dj-stripe provisioning, not a checkout — a $0 subscription_update invoice at creation, no payment intent, customer metadata djstripe_subscriber + tma_user_uuid. Nothing mints sales outside the two stamped estates. Question closed.

What to do — two writes, one sitting

  1. Server: at the quiz rail’s create-intent (the handler behind /payments/quiz-checkout/), copy the checkout_version the request body already delivers into the PaymentIntent metadata at create — the same write the sub already gets, one object earlier. Genuinely one line: the client half is verified already shipping.
  2. Client: in the quiz checkout’s purchase-side events (dataLayer/gtag/fbq), send checkout_version:'quiz_checkout' alongside the quiz_variant the screen events already carry — same shape v3’s analytics.js emits, so both estates report identically.

How we will both know it worked

# 1. next real quiz sale — the PI itself carries the estate
stripe payment_intents list --limit 5   # newest quiz PI: metadata.checkout_version = quiz_checkout

# 2. the served bundle carries the stamp
curl -s https://quiz.themovementathlete.com/2026-quiz-checkout-v1/assets/index-*.js | grep -c checkout_version   # must be > 0

We re-run both checks the day you say it shipped — same as every card on this page.