Domain Broker v2 Launch + get-started A/B — Execution Runbook

2026-07-22 · Ben (spec by audit worker) · strategy

Domain Broker v2 Launch + get-started A/B — Execution Runbook

✅ PART 1 LAUNCHED — 2026-07-22 (prod-2026-07-22-001). /domain-broker/ (English) serves v2 (Minimal hero + FormV2) to bots + humans, indexed, hero A/B ended (HERO_AB_ACTIVE=false, vpn-translate e81136a). Site→prod at 20:17:47Z, worker flag→false at 20:20:38Z. Verified: Googlebot v2, no noindex/X-Robots, self-canonical, /fr/ hub stays v1. bowtie note #180. A/B data cut: pre-20:17:47Z D1 hero_variant only (sticky cookie corrupts later rows).

⏸ PART 2 PARKED — needs editing DomainBrokerFormV2.astro (the gs_variant field), which hits the user's coordination gate (possible collision with the other worker). Awaiting user go + collision sync. (A route that avoids the form edit exists — read the ab_…get-started cookie server-side in domain-intake.js, gated on referer path — surfaced as a proposal.)

PART 3 (i18n) — later push, unchanged.

Goal (from July 21 meeting + Ben):

  1. /domain-broker/ (organic hub): promote v2 (the Minimal hero + DomainBrokerFormV2) to 100% of traffic, permanent and indexed. End the hero A/B.
  2. /domain-broker/get-started/ (paid lander): start a fresh A/B — control = current legacy flat form, variant B = the same DomainBrokerFormV2 treatment.

This is the meeting's "v2 → 100% organic, A/B on paid (flip switch)" split, made concrete.

Deliverable mode is spec/doc — this runbook is written for the implementing worker. Nothing here has been executed.


⚠️ Read this first — the switch trap

The hero A/B is currently LIVE on prod (vpn-workers/translate/wrangler.tomlHERO_AB_ACTIVE = "true", commit 96107a2). Variant a = current hero, variant b = the Minimal hero-v2 build.

"Turn off the A/B" and "make v2 live" are OPPOSITE switches. Setting HERO_AB_ACTIVE="false" reverts 100% of traffic to variant a — the OLD hero. It does NOT promote v2. Do not just flip the flag.

v2 must be promoted at the page level so bots, organic, and everyone get it, properly indexed. The flag then goes false only after the origin page is already v2.


Part 1 — Promote v2 to 100% on /domain-broker/

The variant-B experience is DomainBrokerPage.astro rendered with heroVariant="b" (swaps DomainBrokerFormDomainBrokerFormV2; every section below the hero is identical). Backend for FormV2's structured domains[] is already shipped and live (migration 0007, domain-intake.js normalizes both shapes).

Steps (in order)

  1. Make the real indexed page = v2. src/pages/domain-broker/index.astro — add the prop:

    <DomainBrokerPage lang="en" heroVariant="b" hreflangMap={hreflangMap} />

    (Currently no heroVariant, so it defaults to 'a'.) This makes /domain-broker/ itself serve the Minimal hero + FormV2 — crawlable, indexed, canonical intact. <h1> stays "Premium Domain Broker" (both variants already share it), so the recovering ranking is untouched.

    Localized hubs — DECIDED: English-only for launch. index.astro is English-only, and DomainBrokerFormV2 is not i18n-ready (see Part 3). Do NOT promote v2 on the translated /{lang}/domain-broker/ hubs yet — they stay on control (v1 flat form) until FormV2 is localized. Ship v2 to /domain-broker/ (English) only.

  2. Stop the worker from swapping. vpn-workers/translate/wrangler.tomlHERO_AB_ACTIVE = "false". With the origin now v2, the worker serves origin directly = v2 for everyone including bots. (Do NOT do this step before step 1 is deployed, or you serve the old hero.)

  3. Deploy — order matters.

    • Deploy the site first: bash scripts/deploy.sh → staging, verify /domain-broker/ shows the Minimal hero (FormV2, tp-kicker H1 class) with no noindex, then bash scripts/promote.sh → prod.
    • Then deploy the worker: bash vpn-workers/translate/deploy.sh (token-guarded).
    • If you flip the worker before prod has v2, real traffic gets the old hero. Site → prod first, worker second.
  4. Verify live (bot + human + index):

    # Bot must now see v2 (Minimal), not old hero:
    curl -s "https://www.vpn.com/domain-broker/" -A "Googlebot" | grep -oiE '<h1[^>]*>[^<]*</h1>'   # expect tp-kicker class
    # No noindex on the canonical page:
    curl -sI "https://www.vpn.com/domain-broker/" | grep -i x-robots-tag   # expect NOTHING
    curl -s  "https://www.vpn.com/domain-broker/" | grep -i 'name="robots"' # expect index,follow or absent
    # Canonical self-referential:
    curl -s "https://www.vpn.com/domain-broker/" | grep -i 'rel="canonical"' # → /domain-broker/
  5. Cleanup (follow-up commit, not launch-blocking):

    • Delete src/pages/domain-broker/hero-v2.astro (it was the noindex clone of v2; now redundant and would be a near-duplicate of the live page).
    • Remove the serveHeroAB branch + helpers from vpn-workers/translate/src/index.js (the pathname === '/domain-broker/' block, lines ~96-100 and the serveHeroAB/buildVariantResponse/finalizeAB/abActive helpers) so the worker has no dead A/B path. Keep the redirect + bot ordering intact — that block runs BEFORE the A/B branch, don't disturb it.
    • Until cleanup ships, HERO_AB_ACTIVE="false" keeps it dormant, so cleanup can be its own PR.
  6. SEO watch: run /before-and-after (or the before-and-after skill) snapshot on /domain-broker/ at promote time; watch "premium domain broker" position weekly. The verdict data from the killed A/B lives in D1 hero_variant if you want the retro.

  7. Announce: mcp__askbowtie__add_note — category deploy, domain vpn.com: "Domain broker v2 (Minimal hero + FormV2) promoted to 100% on /domain-broker/; hero A/B ended (HERO_AB_ACTIVE=false)."


Part 2 — Start the get-started A/B (paid lander)

✅ PART 2 LAUNCHED — 2026-07-22 21:52:22Z. Site→prod prod-2026-07-22-002; worker GETSTARTED_AB_ACTIVE="true" (vpn-translate deployed). Test domain-broker-get-started-2026 live on /domain-broker/get-started/: a=legacy flat DomainBrokerForm, b=DomainBrokerFormV2, 50/50 sticky, bots→control. Attribution proven end-to-end (gs=a/gs=b tagged; hub lead w/ get-started cookie → gs=NULL, no pollution; hero regression clean). Prod bucketing verified 50/50 + sticky. bowtie note #182; exposure flows get-started-a (#47) / get-started-b (#48). Judge from D1 gs_variant (SELECT gs_variant, COUNT(*) FROM domain_intake WHERE gs_variant IN ('a','b') GROUP BY gs_variant). Kill switch: GETSTARTED_AB_ACTIVE="false" + redeploy worker. Cleanup: delete 3 GHL test contacts peetermans.b+gsattrib{a,b,hub}@gmail.com (D1 rows already removed).

/domain-broker/get-started/ (src/pages/domain-broker/get-started.astro) is the paid lander — no header, form-first, uses the legacy flat DomainBrokerForm. Test whether the structured DomainBrokerFormV2 (the v2 treatment) converts better here too.

This mirrors the hero-A/B mechanism exactly — reuse it, don't reinvent. Same worker, same caching model, same bot→control rule.

Build steps

  1. Variant-B build pagesrc/pages/domain-broker/get-started-v2.astro:

    • Copy get-started.astro, swap <DomainBrokerForm /> (line 57) → <DomainBrokerFormV2 lang="en" />.
    • Add noindex + canonicalUrl → /domain-broker/get-started/ on the Base layout (same pattern as hero-v2.astro).
    • Exclude from sitemap (mirror how hero-v2 is excluded).
  2. Worker branchvpn-workers/translate/src/index.js:

    • Add a second path-scoped branch: if (pathname === '/domain-broker/get-started/')serveGetStartedAB(...), placed after the redirect lookup + bot pass-through (same position as the hero branch — see [[project_translate_bot_redirect_order]], redirect lookup MUST run first).
    • Clone the hero helpers with a new test id / cookie / origin path (variant b fetches /domain-broker/get-started-v2/). New flag GETSTARTED_AB_ACTIVE in wrangler.toml (start "false", flip to "true" to launch; staging always-on via the same host-aware abActive pattern).
    • New cache-key namespace (e.g. ?__abcache_gs=<v>) so the two tests never share a cached body.
  3. Attribution — do NOT pollute the hero arm. DomainBrokerForm and DomainBrokerFormV2 are shared across /domain-broker/ and /domain-broker/get-started/. Per the Fable note in the hero plan, a hidden variant field must be set only when its own cookie exists AND the path matches — never defaulted, or every get-started/bot/no-cookie lead dumps into an arm.

    • Set a separate hidden field for this test (e.g. gs_variant), gated on ab_domain-broker-get-started-2026 cookie AND path /domain-broker/get-started/. Missing = null, never 'a'.
    • /api/domain-intake (vpn-workers/forms/src/domain-intake.js): store gs_variant in a new D1 column on domain_intake (mirror the hero_variant column from migration 0007). Add it to the domain_inquiry payload.
    • Judge the winner from D1 gs_variant (definitive count). itbroke ab_variant events = exposure denominator only. Do not chain browser→server events through a bowtie flow (synthetic session, won't chain — our gateway finding).
  4. Launch:

    • Deploy site (variant build) → staging, verify ?v=b shows FormV2, ?v=a shows the flat form, bots get control, no noindex leak on the real URL. Promote.
    • Set GETSTARTED_AB_ACTIVE="true" + bash vpn-workers/translate/deploy.sh.
    • Create askbowtie flows at activation (baseline-window trap): get-started-a / get-started-b = ab_variant(test=domain-broker-get-started-2026, variant=a/b) → domain_inquiry(gs_variant=a/b). Trend-only.
    • Set an end date (~3-4 wk; paid volume dictates power).
    • add_note on launch.

Kill switch

GETSTARTED_AB_ACTIVE="false" + redeploy = instant full revert (cookied users too), same honest-kill semantics as the hero flag. Hard kill = delete get-started-v2.astro.



Part 3 — Make DomainBrokerFormV2 i18n-ready (AFTER Parts 1 & 2 are live)

Ben: "once that is in place, we need to prep the v2 form to be i18n ready." This unlocks promoting v2 on the translated /{lang}/domain-broker/ hubs later.

Current state (audited): DomainBrokerFormV2.astro (2,485 lines) takes a lang prop but has zero t()/tx()/resolveUrl() calls — it is fully hardcoded English. This is real work, not a cleanup pass.

Scope:

  1. Extract every visible string in the markup (step labels "Domains / Personal Details / Agreement", field labels, the agreement legal copy at the bottom, buttons, placeholders, timeline options) into src/i18n/translations.ts as a named export (e.g. domainBrokerFormV2), keyed across all 16 languages. Follow the New Component pattern in CLAUDE.md.
  2. Client-side JS strings: the form is a multi-step engine — validation messages, dynamic row copy, and any strings inside the inline <script> must be passed in via a <script type="application/json"> config block (the PROJECT.md client-side i18n pattern), not hardcoded.
  3. Localize internal links with resolveUrl() / localizeUrl().
  4. translations.ts is a contamination-landmine SSOT — pull up LayerView before editing (lv describe file:src/i18n/translations.ts vpn-astro), record the change after (lv remember file:src/i18n/translations.ts -s changes "…" vpn-astro), and run node scripts/i18n-contamination-check.mjs --gate + node scripts/translation-qa.mjs after build. The build gate fails the deploy otherwise.
  5. Translate the new strings (scripts/translate-ui-strings.mjs), then promote v2 on the localized hubs by passing heroVariant="b" through the [lang]/domain-broker/ routes.

This is a separate push (likely Tuesday+), gated on Parts 1-2 being live and verified.


Confirmed scope (Ben, 2026-07-22)

Coordination flag

Part 2 + Part 3 both touch DomainBrokerFormV2 (and Part 3 touches translations.ts) — the same files the other worker may be in for provider/flow work. Sync before starting to avoid collision.

Bottom line

Part 1 is small and Friday-ready: flip index.astro to heroVariant="b", deploy site→prod, then set HERO_AB_ACTIVE="false" and deploy the worker — in that order. Do not flip the flag alone; that ships the old hero. Part 2 clones the proven hero-A/B mechanism for the paid lander with strict per-test attribution gating, judged from a new D1 gs_variant column.