Privacy Compliance — Implementation Plan
Status: done
Implements privacy-compliance.md. Closes the divergence noted in Known gaps.
Goal
Build the privacy-compliance floor from scratch — extend ConsentService for advertising, ship a /privacy-choices opt-out page, propagate opt-out to client + server tracking, honor GPC, and rewrite the privacy policy to match actual data flows.
Status
- [x] 1. Extend
ConsentServicefor advertising channel - [x] 2. Anon cookie helpers + GPC detection (
apps/web/src/lib/privacy-choices.ts) - [x] 3. Middleware:
Sec-GPC: 1→ set opt-out cookie - [x] 4. Root layout: Consent Mode v2 default + Meta
consent revokefrom cookie - [x] 5. Client tracking gate (
apps/web/src/lib/tracking.ts) - [x] 6. Server CAPI gate —
track-purchase.ts - [x] 7. Server CAPI gate —
track-lead.ts - [x] 8. Server CAPI gate —
track-checkout-init.ts - [x] 9. Server CAPI gate —
track-registration.ts - [x] 10.
/privacy-choicespage + server action - [x] 11. Footer "YOUR PRIVACY CHOICES" link
- [x] 12. Anon cookie →
ConsentServicemerge on signin - [x] 13. Privacy policy rewrite
- [x] 14. Spec follow-up — close Known Gaps, drop (new) annotations
Driving citations
- Spec Invariants — opt-out propagation, durability/auditability, GPC authority
- Spec Data flow surface — Meta Pixel, CAPI, Google Ads must respect opt-out; service providers exempt
- Spec Contracts — footer link, /privacy-choices toggle behavior, server-side resolver, GPC handling, DSAR
- Spec Cross-system dependencies —
ConsentServiceis primary persistence
Work breakdown
1. Extend ConsentService for advertising channel
- Files:
apps/api/src/services/consent/ConsentService.ts,ConsentService.test.ts(or sibling test file) - Changes:
- Add
'advertising'toCHANNELS - Add
'cross_context_behavioral'toPURPOSES - Add
'gpc_header'toSOURCES - Add static method
isOptedOutOfAdvertising(email: string, tx: TransactionClient): Promise<boolean>readingconsentStatefor(email, 'advertising', 'cross_context_behavioral'), returningstate?.status === 'opted_out'(default-on; advertising is opt-out, not opt-in like SMS marketing) - Update
canSendif needed — advertising is not a delivery channel, so callers should useisOptedOutOfAdvertisingdirectly - Tests: new channel/purpose round-trips through
recordConsent; resolver returns false when no record, true on opt-out, false after subsequent opt-in - Spec coverage: Invariant 2, File map → Authenticated opt-out persistence
2. Anon cookie helpers + GPC detection
- Files:
apps/web/src/lib/privacy-choices.ts(new),privacy-choices.test.ts(new) - Changes:
- Constants:
COOKIE_NAME = 'mg_privacy_choices',COOKIE_MAX_AGE = 60 * 60 * 24 * 365, values'opted-out' | 'opted-in' readPrivacyChoiceFromCookie(): 'opted-out' | 'opted-in' | null(browser-only, readsdocument.cookie)writePrivacyChoiceCookie(value, response?)— supports both browser write and server-sideNextResponsewriteisGpcSignaled(headers: Headers): boolean— checksSec-GPC: 1- Tests: cookie parse/write round-trip, GPC header detection (1 vs 0 vs absent)
- Spec coverage: Invariant 2, GPC handling
3. Middleware: Sec-GPC: 1 → set opt-out cookie
- Files:
apps/web/src/middleware.ts - Changes:
- After existing maintenance bypass logic, before
NextResponse.next(), checkisGpcSignaled(request.headers)and absence ofmg_privacy_choicescookie; if both, attachSet-Cookieto the response - Never override an existing cookie (explicit user choice wins over GPC reapplication)
- Tests: integration test against the middleware — request with
Sec-GPC: 1and no cookie → cookie set; request with cookie present → not overwritten; request without GPC → no change - Spec coverage: Invariant 3, GPC handling
4. Root layout: Consent Mode v2 default + Meta consent revoke
- Files:
apps/web/src/app/layout.tsx - Changes:
- Read
mg_privacy_choicescookie server-side vianext/headers cookies() - Before
gtag('config', ...), emitgtag('consent', 'default', { ad_storage, ad_user_data, ad_personalization, analytics_storage })— values reflect cookie state ('denied'if opted-out,'granted'otherwise) - Before Pixel init script, if opted-out, prepend
fbq('consent', 'revoke'); if opted-in, no-op (Pixel default isgrant) - Pass cookie state into a small client component or data attribute so toggle on
/privacy-choicescan re-emitgtag('consent', 'update', ...)andfbq('consent', 'grant'|'revoke')without reload - Tests: rendering test asserts
gtag('consent', 'default', ...)script present with correct values for both cookie states - Spec coverage: Invariant 1, Client-side opt-out check
5. Client tracking gate (apps/web/src/lib/tracking.ts)
- Files:
apps/web/src/lib/tracking.ts,tracking.test.ts - Changes:
- In
trackMetaEventandtrackGoogleConversion, early-return ifreadPrivacyChoiceFromCookie() === 'opted-out' trackEvent(Umami / first-party analytics) is unaffected- Document that callers don't need to check separately — gating is centralized
- Tests: mock cookie → opted-out, assert
window.fbqandwindow.gtagnot called for the conversion path; opted-in or null → both called - Spec coverage: Invariant 1, Invariant 4
6. Server CAPI gate — track-purchase.ts
- Files:
apps/api/src/lib/meta/track-purchase.ts,track-purchase.test.ts - Changes:
- At top of
trackPurchaseEventToMeta, ifinput.user.emailpresent, callConsentService.isOptedOutOfAdvertising(email, tx)(threadtxthrough if not already; otherwise use a transaction-less prisma client at the boundary) - If opted out, return early without calling
sendCapiEvents - Add the
txparam if needed (consult existing callers — most route handlers already have atx) - Tests: existing tests continue passing; new test asserts no CAPI call when opted out
- Spec coverage: Server-side opt-out check
7. Server CAPI gate — track-lead.ts
- Files:
apps/api/src/lib/meta/track-lead.ts,track-lead.test.ts - Changes: same shape as item 6
- Spec coverage: Server-side opt-out check
8. Server CAPI gate — track-checkout-init.ts
- Files:
apps/api/src/lib/meta/track-checkout-init.ts,track-checkout-init.test.ts - Changes: same shape as item 6
- Spec coverage: Server-side opt-out check
9. Server CAPI gate — track-registration.ts
- Files:
apps/api/src/lib/meta/track-registration.ts,track-registration.test.ts - Changes: same shape as item 6. Note: registration is the one place where the User record is created in the same transaction as the CAPI call — the opt-out lookup should run after user creation (so cookie merge from item 12 has already written the consent event).
- Spec coverage: Server-side opt-out check
10. /privacy-choices page + server action
- Files:
apps/web/src/app/(marketing)/privacy-choices/page.tsx(new)apps/web/src/app/(marketing)/privacy-choices/actions.ts(new)apps/web/src/app/(marketing)/privacy-choices/PrivacyChoicesForm.tsx(new, client component)- Changes:
- Page reads cookie server-side; if user authenticated, also reads
consentStatefor advertising channel and merges (auth state is authoritative when both present) - Form has one toggle: "Opt out of sharing my personal information for advertising"
- Submit: server action → if authed,
ConsentService.recordConsent({ channel: 'advertising', purpose: 'cross_context_behavioral', source: 'preference_center', action: 'opt_out' | 'opt_in', userId }); always also writes the cookie so client-side gate is consistent on next pageview - Client component fires
gtag('consent', 'update', ...)andfbq('consent', 'grant'|'revoke')immediately on save - Disclosure text: "We honor Global Privacy Control signals automatically."
- Tests: server action persists correctly for authed; cookie set for anon; form reflects current state on render
- Spec coverage: /privacy-choices page
11. Footer "YOUR PRIVACY CHOICES" link
- Files:
apps/web/src/components/organisms/layout/Footer.tsx - Changes: Add
<Link href="/privacy-choices">YOUR PRIVACY CHOICES</Link>between PRIVACY POLICY and TERMS OF SERVICE links - Tests: snapshot test (existing pattern) updated to include new link
- Spec coverage: Footer link
12. Anon cookie → ConsentService merge on signin
- Files: signin handler — likely
apps/web/src/app/(auth)/login/actions.tsor wherever post-signin hooks fire (TBD via grep when iteration starts); also signup path - Changes:
- After successful auth, read
mg_privacy_choicescookie. If'opted-out', callConsentService.recordConsent({ contactPoint: user.email, channel: 'advertising', purpose: 'cross_context_behavioral', action: 'opt_out', source: 'preference_center', userId: user.id })(or'gpc_header'if a separate cookie marker tracks GPC origin — see note) - Clear the cookie after merge to prevent double-recording
- Note on source attribution: to distinguish "user toggled" from "GPC auto-set" cookies, item 2 should add a second cookie value or a sibling cookie (
mg_privacy_choices_source: 'user' | 'gpc'). Resolve during item 2; merge step reads it. - Tests: signin with opted-out cookie →
consentEventrow written, cookie cleared; signin with no cookie → no event - Spec coverage: Invariant 2, Regression test
13. Privacy policy rewrite
- Files:
apps/web/src/app/(marketing)/privacy/page.tsx - Changes:
- Bump version to V 2.0.0, update revision date
- Remove "no-sell, no-share" claim (it's false — we share for advertising)
- Replace "Google Analytics" with accurate vendor list (Meta Pixel, Meta CAPI, Google Ads, Umami or PostHog, Sentry, Stripe, Supabase, Resend, Twilio, Vercel)
- Add "Sharing for advertising" section explicitly listing Meta + Google Ads recipients and what's shared
- Add "Your California / Oregon / Texas / Colorado / Connecticut / Virginia Privacy Rights" section: right to know, right to delete, right to correct, right to opt out of sharing, right to data portability, non-discrimination
- Reference
/privacy-choicespage for opt-out andhello@metrognome.comfor DSAR - Update DSAR SLA to 45 days (was 30); add 10-day acknowledgment commitment
- Reaffirm under-18 minimum (not under-13 as COPPA — we're stricter, matches studio policy)
- Cross-check stated retentions against deletion runbooks (open question per Known Gaps — escalate if mismatch)
- Tests: none (static content) — visual review on staging
- Spec coverage: Data flow surface, DSAR intake, Jurisdictional baseline, Invariant 5, Invariant 6
14. Spec follow-up
- Files:
docs/features/privacy-compliance/spec.md,docs/features/privacy-compliance/plan.md,docs/features/README.md - Changes:
- Update Known Gaps in spec — close items resolved during implementation (PostHog/Umami row finalized to whichever shipped; retention review status; legal review status)
- Drop any
(new)annotations from File map paths now that files exist - Set
**Status:** doneon this plan - Update
docs/features/README.mdPlans cell to[done](./privacy-compliance-plan.md) - Spec coverage: divergence-closing step
Notes
- Items 6–9 are deliberately split per file (not collapsed) so each ralph iteration is independently testable. They share a pattern; once item 6 lands, 7–9 are mechanical.
- Item 12's source-attribution decision (user vs GPC origin) is a small open question — resolve during item 2 by either adding a sibling cookie or encoding origin in the value (
'opted-out:user' | 'opted-out:gpc'). - Item 13 has a hidden risk: the retention review (open question in Known Gaps) may force code changes elsewhere (e.g., a deletion job) before the policy can ship truthfully. Flag it to Aaron when item 13 starts; do not silently update the policy to match drift.
- Item 9 has an ordering subtlety with item 12 noted inline.