Skip to content

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 ConsentService for 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 revoke from 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-choices page + server action
  • [x] 11. Footer "YOUR PRIVACY CHOICES" link
  • [x] 12. Anon cookie → ConsentService merge 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 dependenciesConsentService is 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' to CHANNELS
  • Add 'cross_context_behavioral' to PURPOSES
  • Add 'gpc_header' to SOURCES
  • Add static method isOptedOutOfAdvertising(email: string, tx: TransactionClient): Promise<boolean> reading consentState for (email, 'advertising', 'cross_context_behavioral'), returning state?.status === 'opted_out' (default-on; advertising is opt-out, not opt-in like SMS marketing)
  • Update canSend if needed — advertising is not a delivery channel, so callers should use isOptedOutOfAdvertising directly
  • 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
  • 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, reads document.cookie)
  • writePrivacyChoiceCookie(value, response?) — supports both browser write and server-side NextResponse write
  • isGpcSignaled(headers: Headers): boolean — checks Sec-GPC: 1
  • Tests: cookie parse/write round-trip, GPC header detection (1 vs 0 vs absent)
  • Spec coverage: Invariant 2, GPC handling
  • Files: apps/web/src/middleware.ts
  • Changes:
  • After existing maintenance bypass logic, before NextResponse.next(), check isGpcSignaled(request.headers) and absence of mg_privacy_choices cookie; if both, attach Set-Cookie to the response
  • Never override an existing cookie (explicit user choice wins over GPC reapplication)
  • Tests: integration test against the middleware — request with Sec-GPC: 1 and no cookie → cookie set; request with cookie present → not overwritten; request without GPC → no change
  • Spec coverage: Invariant 3, GPC handling
  • Files: apps/web/src/app/layout.tsx
  • Changes:
  • Read mg_privacy_choices cookie server-side via next/headers cookies()
  • Before gtag('config', ...), emit gtag('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 is grant)
  • Pass cookie state into a small client component or data attribute so toggle on /privacy-choices can re-emit gtag('consent', 'update', ...) and fbq('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 trackMetaEvent and trackGoogleConversion, early-return if readPrivacyChoiceFromCookie() === '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.fbq and window.gtag not 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, if input.user.email present, call ConsentService.isOptedOutOfAdvertising(email, tx) (thread tx through if not already; otherwise use a transaction-less prisma client at the boundary)
  • If opted out, return early without calling sendCapiEvents
  • Add the tx param if needed (consult existing callers — most route handlers already have a tx)
  • 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 consentState for 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', ...) and fbq('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
  • 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
  • Files: signin handler — likely apps/web/src/app/(auth)/login/actions.ts or wherever post-signin hooks fire (TBD via grep when iteration starts); also signup path
  • Changes:
  • After successful auth, read mg_privacy_choices cookie. If 'opted-out', call ConsentService.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 → consentEvent row 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-choices page for opt-out and hello@metrognome.com for 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:** done on this plan
  • Update docs/features/README.md Plans 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.