You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In-progress plugin work:wcpos/woocommerce-pos branch feature/anon-id-identity adds wcpos_anon_id; the licence-activation request will carry wcpos_anon_id + site_uuid (the reconciler's strongest join key).
No server changes were attempted as part of filing this issue. Owner: Paul.
⚠️ Stack reconciliation — read first
The spec text (written at the charter level) says "WooCommerce checkout" / "Woo order meta" for wcpos.com. That is not this repo's stack. Verified from the codebase:
This site is Next.js 16 (App Router, next-intl, middleware-based cookie handling), deployed on Vercel.
Commerce runs on Medusa; licensing runs on Keygen CE (see CONTEXT.md). Order completion happens at POST /api/store/cart/complete via completeCart() in src/services/core/external/medusa-client.ts — there is no WooCommerce and no WP order-meta here.
So "Woo order-meta copy at checkout" is implemented as Medusa order metadata written at cart completion. This plan is grounded in the actual stack; the spec's Woo phrasing is treated as charter-level shorthand.
This repo already has substantial attribution plumbing that month-0 work should extend rather than rebuild:
Existing asset
File
Relevance
Server-set distinct-id cookie wcpos-distinct-id (1yr, sameSite=lax, set in middleware)
PostHog is self-hosted at ph.wcpos.com (per program memory; env var NEXT_PUBLIC_POSTHOG_HOST). Join the same project the landing page uses.
GDPR note / open decision for Paul. The spec asks for an unconditional 90-day wcpos_attr cookie on arrival and server-side capture. This repo currently sets the distinct-id cookie and captures only after wcpos-analytics-consent === 'granted' (CNIL/GDPR posture). The recommended reconciliation: treat inbound aid/utm/lv as opaque, non-PII campaign params carried in a strictly-functional first-party cookie, but keep PostHog capture consent-gated exactly as today. Flag this tension explicitly in implementation; do not silently weaken the existing consent gate.
Month-0 scope (ships month 0 per §6.4)
1. PostHog pageview + UTM/aid capture on wcpos.com (arrival baselines)
So tier-2 experiments have arrival baselines before they need them.
Capture a pageview + the inbound utm_* and aid query params on wcpos.com landings (especially /, /pro, /pro/checkout).
Reuse the existing PostHog client wiring; register aid, utm_source/medium/campaign/term/content, lv, pv, ref as event/super properties on first arrival.
Consent-gated to match posthog-service.ts / middleware behaviour (no capture before granted).
Acceptance: A wp-admin → wcpos.com click with ?ref=wp-admin&aid=…&utm_*=…&lv=indie&pv=1.9.3 produces a PostHog pageview carrying those params (post-consent). Params are URL-decoded and length-capped; unknown params ignored. Test: Unit test param extraction/normalisation; e2e (Playwright, already in repo) asserting the capture call fires with the expected props after consent granted, and does not fire pre-consent.
2. Server-set 90-day wcpos_attr first-party cookie on arrival
Carries aid / utm_* / lv so attribution survives the gap between arrival and a later (possibly multi-visit) checkout. Server-set, not document.cookie — Safari ITP caps JS-written cookie lifetimes to ~7 days; a server Set-Cookie is not subject to that cap.
Set-cookie point in this stack:src/middleware.ts — alongside the existing withDistinctIdCookie(request, response) helper. Add a sibling withAttributionCookie() that, when the request URL carries aid/utm_*/lv (and they are not already captured), sets wcpos_attr via response.cookies.set(...). Cookie options mirror getDistinctIdCookieOptions() but maxAge = 60*60*24*90, sameSite: 'lax', secure in prod, httpOnly: true (server-only; nothing client-side needs to read it — this is stricter than the distinct-id cookie and avoids ITP entirely).
Value: compact JSON (or URL-encoded kv) of { aid, utm_*, lv, pv, ref, first_seen_at }. First-write-wins for the campaign attributes (don't overwrite an existing attribution on a later organic visit); refresh only first_seen_at is not done — the point is to preserve the original touch.
Respect consent: only set when consent is granted (or carry the documented "functional campaign param" exception above, per Paul's decision).
Acceptance: First arrival with attribution params yields a Set-Cookie: wcpos_attr=…; Max-Age=7776000; HttpOnly; SameSite=Lax. A second arrival with different params does not clobber the first. Cookie absent when no attribution params and none previously set. Test:middleware.test.ts (exists) cases: params present → cookie set with 90d/HttpOnly; cookie already present → not overwritten; no params → no cookie; consent denied → no cookie (or functional-exception path, whichever Paul picks).
3. Checkout: copy aid/UTM/lv from the cookie into order metadata
At order creation, persist the attribution so the reconciler can join on order_id.
Hook in src/app/api/store/cart/complete/route.ts (the existing completion path). Read wcpos_attr from cookies(), parse it, and write aid/utm_*/lv/pv/ref into Medusa order metadata via the cart/order metadata API in medusa-client.ts (the Medusa equivalent of "order meta").
Also fold the same fields into the existing checkout_completed server event (it already emits order_id, distinct_id, variant).
Idempotent: writing metadata for an already-completed order is a no-op.
Acceptance: A completed order whose session had wcpos_attr has metadata.aid, metadata.utm_*, metadata.lv set and matching the cookie. Missing cookie → metadata fields absent/null, completion still succeeds (attribution is best-effort, never blocks checkout). Test: Extend route.test.ts (exists): cookie present → metadata write asserted; cookie absent → completion still 200, no metadata write, no throw; malformed cookie → fails closed, logged, checkout unaffected.
The reconciler (lives here — the only system that sees orders, refunds, and licence activations)
A scheduled job that joins three sources and writes attribution truth to PostHog. It lives in wcpos.com because this is the only system with visibility into Medusa orders/refunds and Keygen licence activations.
Join sources
Medusa order metadata — aid/utm_*/lv written at checkout (above), keyed by order_id. Refund/credit state from Medusa.
Keygen licence activations — once the plugin PR ships, each activation carries wcpos_anon_id + site_uuid (via src/services/core/external/license-client.ts / keygen). This is the strongest exposure join: it maps a paying site back to the aid the landing page emitted.
wcpos_attr cookie value — last-resort join for the same browser session when order metadata is thin.
Behaviour
Scheduled job. Implement as a Vercel Cron-invoked authenticated route (e.g. src/app/api/cron/reconcile-attribution/route.ts, protected by a CRON_SECRET bearer check — Vercel Cron pattern). Runs e.g. hourly/daily; processes orders since the last watermark.
Idempotent per order_id. Maintain a processed-order watermark / dedup key so re-runs never double-emit. pro_purchase_attributed fires at most once per order_id.
Emits to PostHog via the existing capture pattern (trackServerEvent / ${host}/capture/):
pro_purchase_attributed — once per order_id; props: order_id, aid, utm_*, lv, pv, site_uuid (when activation-joined), amount, sku, is_lifetime, join_source: order_meta|activation|cookie.
pro_purchase_refunded — on a refund/revocation; carries order_id + original attribution; drives refund-netting.
pro_purchase_unattributed — order_id with no resolvable aid after all three joins (feeds the coverage metric's denominator gap).
rekeyed_site flag — set on attributions whose id (anon_id/site_uuid) has no exposure history in PostHog (e.g. a site that rotated its anon_id, or activated without ever hitting the instrumented landing page). Emitted as a property so these are analysable separately and don't silently inflate/deflate attribution.
Refund-netting — attributed purchase counts are net of refunds over the reporting window; the reconciler is the source of truth for the refund-adjusted posterior the program reports (beta-binomial, directional — never "significant", per §5.3).
Attribution-coverage metric — attributed / (attributed + unattributed) per month; target ≥80% monthly (this is the §6.5 "Attribution Health" dashboard input). Emit a periodic attribution_coverage event/metric or expose via HogQL.
Acceptance criteria
Re-running the job over the same window emits zero duplicate pro_purchase_attributed events (idempotency proven by watermark + dedup test).
An order joined via activation gets join_source: activation and a populated site_uuid; an order joined only via cookie gets join_source: cookie.
A refunded order emits pro_purchase_refunded and is excluded from the net attributed count.
An order with no resolvable id emits pro_purchase_unattributed and counts only in the coverage denominator.
An attribution whose id has no prior exposure carries rekeyed_site: true.
Monthly coverage is computed and surfaced; alert/flag when < 80%.
wcpos.com arrival capture (#1), wcpos_attr cookie (#2), and checkout order-meta (#3) ship month 0. The reconciler ships month 0 too, but its activation join only becomes fully effective once the plugin's feature/anon-id-identity PR lands and activations begin carrying wcpos_anon_id + site_uuid; until then it joins via order metadata + cookie and reports the coverage gap honestly.
Landing experiments: attribution capture + purchase reconciler (spec §7b)
Part of the WCPOS landing-experiments program. This is the wcpos.com plan (plan 3 of 4) from the program charter.
docs/superpowers/specs/2026-06-12-landing-experiments-design.md§7b (and §5.3 cross-domain attribution, §6.4 month-0 sequencing) inwcpos/wp-admin-landing.ref=wp-admin,utm_*,lv={variant},aid={distinct_id},pv={plugin_version}(opaque IDs only;aidis anon_id or site_uuid depending on consent state).wcpos/woocommerce-posbranchfeature/anon-id-identityaddswcpos_anon_id; the licence-activation request will carrywcpos_anon_id+site_uuid(the reconciler's strongest join key).The spec text (written at the charter level) says "WooCommerce checkout" / "Woo order meta" for wcpos.com. That is not this repo's stack. Verified from the codebase:
next-intl, middleware-based cookie handling), deployed on Vercel.CONTEXT.md). Order completion happens atPOST /api/store/cart/completeviacompleteCart()insrc/services/core/external/medusa-client.ts— there is no WooCommerce and no WP order-meta here.This repo already has substantial attribution plumbing that month-0 work should extend rather than rebuild:
wcpos-distinct-id(1yr,sameSite=lax, set in middleware)src/middleware.ts,src/lib/analytics/distinct-id.tswcpos_attrcookie should follow this exact server-set patternwcpos-analytics-consent+ gatingsrc/lib/analytics/consent.tsresolveProCheckoutVariant,trackServerEvent(POSTs to${host}/capture/)src/services/core/analytics/posthog-service.tstrackServerEvent/the capture pattern; flag eval already hits${host}/flags?v=2checkout_completedwithorder_id,customer_id,distinct_id,experiment,variantsrc/app/api/store/cart/complete/route.tssrc/lib/analytics/config.ts,src/utils/env.ts(NEXT_PUBLIC_POSTHOG_HOST,POSTHOG_API_KEY)PostHog is self-hosted at ph.wcpos.com (per program memory; env var
NEXT_PUBLIC_POSTHOG_HOST). Join the same project the landing page uses.Month-0 scope (ships month 0 per §6.4)
1. PostHog pageview + UTM/
aidcapture on wcpos.com (arrival baselines)So tier-2 experiments have arrival baselines before they need them.
utm_*andaidquery params on wcpos.com landings (especially/,/pro,/pro/checkout).aid,utm_source/medium/campaign/term/content,lv,pv,refas event/super properties on first arrival.posthog-service.ts/ middleware behaviour (no capture beforegranted).Acceptance: A wp-admin → wcpos.com click with
?ref=wp-admin&aid=…&utm_*=…&lv=indie&pv=1.9.3produces a PostHog pageview carrying those params (post-consent). Params are URL-decoded and length-capped; unknown params ignored.Test: Unit test param extraction/normalisation; e2e (Playwright, already in repo) asserting the capture call fires with the expected props after consent granted, and does not fire pre-consent.
2. Server-set 90-day
wcpos_attrfirst-party cookie on arrivalCarries
aid/utm_*/lvso attribution survives the gap between arrival and a later (possibly multi-visit) checkout. Server-set, notdocument.cookie— Safari ITP caps JS-written cookie lifetimes to ~7 days; a serverSet-Cookieis not subject to that cap.src/middleware.ts— alongside the existingwithDistinctIdCookie(request, response)helper. Add a siblingwithAttributionCookie()that, when the request URL carriesaid/utm_*/lv(and they are not already captured), setswcpos_attrviaresponse.cookies.set(...). Cookie options mirrorgetDistinctIdCookieOptions()butmaxAge = 60*60*24*90,sameSite: 'lax',securein prod,httpOnly: true(server-only; nothing client-side needs to read it — this is stricter than the distinct-id cookie and avoids ITP entirely).{ aid, utm_*, lv, pv, ref, first_seen_at }. First-write-wins for the campaign attributes (don't overwrite an existing attribution on a later organic visit); refresh onlyfirst_seen_atis not done — the point is to preserve the original touch.granted(or carry the documented "functional campaign param" exception above, per Paul's decision).Acceptance: First arrival with attribution params yields a
Set-Cookie: wcpos_attr=…; Max-Age=7776000; HttpOnly; SameSite=Lax. A second arrival with different params does not clobber the first. Cookie absent when no attribution params and none previously set.Test:
middleware.test.ts(exists) cases: params present → cookie set with 90d/HttpOnly; cookie already present → not overwritten; no params → no cookie; consent denied → no cookie (or functional-exception path, whichever Paul picks).3. Checkout: copy
aid/UTM/lvfrom the cookie into order metadataAt order creation, persist the attribution so the reconciler can join on
order_id.src/app/api/store/cart/complete/route.ts(the existing completion path). Readwcpos_attrfromcookies(), parse it, and writeaid/utm_*/lv/pv/refinto Medusa order metadata via the cart/order metadata API inmedusa-client.ts(the Medusa equivalent of "order meta").checkout_completedserver event (it already emitsorder_id,distinct_id,variant).Acceptance: A completed order whose session had
wcpos_attrhasmetadata.aid,metadata.utm_*,metadata.lvset and matching the cookie. Missing cookie → metadata fields absent/null, completion still succeeds (attribution is best-effort, never blocks checkout).Test: Extend
route.test.ts(exists): cookie present → metadata write asserted; cookie absent → completion still 200, no metadata write, no throw; malformed cookie → fails closed, logged, checkout unaffected.The reconciler (lives here — the only system that sees orders, refunds, and licence activations)
A scheduled job that joins three sources and writes attribution truth to PostHog. It lives in wcpos.com because this is the only system with visibility into Medusa orders/refunds and Keygen licence activations.
Join sources
aid/utm_*/lvwritten at checkout (above), keyed byorder_id. Refund/credit state from Medusa.wcpos_anon_id+site_uuid(viasrc/services/core/external/license-client.ts/ keygen). This is the strongest exposure join: it maps a paying site back to theaidthe landing page emitted.wcpos_attrcookie value — last-resort join for the same browser session when order metadata is thin.Behaviour
src/app/api/cron/reconcile-attribution/route.ts, protected by aCRON_SECRETbearer check — Vercel Cron pattern). Runs e.g. hourly/daily; processes orders since the last watermark.order_id. Maintain a processed-order watermark / dedup key so re-runs never double-emit.pro_purchase_attributedfires at most once perorder_id.trackServerEvent/${host}/capture/):pro_purchase_attributed— once perorder_id; props:order_id,aid,utm_*,lv,pv,site_uuid(when activation-joined),amount,sku,is_lifetime,join_source: order_meta|activation|cookie.pro_purchase_refunded— on a refund/revocation; carriesorder_id+ original attribution; drives refund-netting.pro_purchase_unattributed— order_id with no resolvableaidafter all three joins (feeds the coverage metric's denominator gap).rekeyed_siteflag — set on attributions whose id (anon_id/site_uuid) has no exposure history in PostHog (e.g. a site that rotated its anon_id, or activated without ever hitting the instrumented landing page). Emitted as a property so these are analysable separately and don't silently inflate/deflate attribution.attributed / (attributed + unattributed)per month; target ≥80% monthly (this is the §6.5 "Attribution Health" dashboard input). Emit a periodicattribution_coverageevent/metric or expose via HogQL.Acceptance criteria
pro_purchase_attributedevents (idempotency proven by watermark + dedup test).join_source: activationand a populatedsite_uuid; an order joined only via cookie getsjoin_source: cookie.pro_purchase_refundedand is excluded from the net attributed count.pro_purchase_unattributedand counts only in the coverage denominator.rekeyed_site: true.Test notes
rekeyed_sitedetection. Mock Medusa + Keygen + PostHog capture (the repo already mocksposthog-serviceinroute.test.ts).CRON_SECRETbearer.Cross-links
docs/superpowers/specs/2026-06-12-landing-experiments-design.md(inwcpos/wp-admin-landing).wcpos/woocommerce-posbranchfeature/anon-id-identity.Sequencing (§6.4)
wcpos.com arrival capture (#1),
wcpos_attrcookie (#2), and checkout order-meta (#3) ship month 0. The reconciler ships month 0 too, but its activation join only becomes fully effective once the plugin'sfeature/anon-id-identityPR lands and activations begin carryingwcpos_anon_id+site_uuid; until then it joins via order metadata + cookie and reports the coverage gap honestly.