Skip to content

feat(tracking): TCF/GPP interop consent reconciler (spec-ad-consent-frameworks) - #612

Merged
thevuong merged 1 commit into
mainfrom
feat/tracking-tcf-gpp-reader
Jul 18, 2026
Merged

feat(tracking): TCF/GPP interop consent reconciler (spec-ad-consent-frameworks)#612
thevuong merged 1 commit into
mainfrom
feat/tracking-tcf-gpp-reader

Conversation

@thevuong

Copy link
Copy Markdown
Contributor

Implements the "read a CMP, don't become one" interop layer (spec-ad-consent-frameworks §1/§3). The system reads an external IAB CMP and reconciles it with the native { ads, analytics } model; it never becomes a certified CMP, mints TC/GPP strings, or ingests the Global Vendor List (the spec's explicit non-goal — no call site needs the system to mint strings).

What

  • reconcileAdFrameworkConsent({ native, cmp, hasGlobalPrivacyControlSignal }) → the reconciled decision, applying §3 precedence:
    • a governing CMP overrides only its categories (V2);
    • fail-closed to denied while the CMP is detected-but-loading (V3);
    • a missing (V1) or out-of-scope / gdprApplies=false (V6) CMP leaves native standing — never force-denies;
    • GPC applied last, so it can only tighten ads, never loosen a CMP grant (V5); a GPP US sale/share opt-out sets ads=false and leaves first-party analytics (V4).
  • hasTcfApi / hasGppApi detect the __tcfapi / __gpp read APIs by function presence, without invoking them.

Deliberately the caller's, per the spec

  • The TCF purpose set + Google GVL vendor id are not hard-coded — that mapping is ad-ops policy (spec §3/§5, flagged UNCERTAIN). The caller derives the CmpConsentSignal (from TCData/GPP over __tcfapi/__gpp) and passes it in; this package owns only the reconciliation.
  • Capturing/forwarding tcString + addtlConsent verbatim to Google tags is the integrator's (the reconciler never mints them).

Verification

  • @codefast/tracking full unit suite: 227 passed (7 new: vectors V1–V6 + API detection)
  • pnpm --filter @apps/ui build — green
  • check-types + oxlint --deny-warnings + oxfmt clean

Note: spec-ad-consent-frameworks's status line still reads "not yet implemented" — the reconciler is now implemented; the CMP read + purpose mapping remain the integrator's. A follow-up spec status refresh could note that split.

Changeset: @codefast/tracking minor.

spec-ad-consent-frameworks: the system reads an external CMP and reconciles it with the
native { ads, analytics } decision — it never becomes a CMP or mints TC/GPP strings.
reconcileAdFrameworkConsent applies the §3 precedence (a governing CMP overrides its
categories; fail-closed to denied while loading; a missing or out-of-scope CMP leaves
native standing; GPC only tightens ads), covering vectors V1-V6. hasTcfApi/hasGppApi
detect the __tcfapi/__gpp read APIs without invoking them. TCF purpose ids and the Google
vendor id are deliberately not hard-coded — that mapping is ad-ops policy, so the caller
derives the CmpConsentSignal it passes in.

Verified: tracking full suite (227) green; apps/ui vite build unaffected; check-types +
oxlint + oxfmt clean.
@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
codefast-tanstack-start Ready Ready Preview, Comment Jul 18, 2026 10:11am
codefastlabs Ready Ready Preview, Comment Jul 18, 2026 10:11am

@codecov

codecov Bot commented Jul 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.66667% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ckages/tracking/src/client/ad-framework-consent.ts 91.66% 0 Missing and 1 partial ⚠️
Files with missing lines Coverage Δ
...ckages/tracking/src/client/ad-framework-consent.ts 91.66% <91.66%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@thevuong
thevuong merged commit 1337fc3 into main Jul 18, 2026
10 checks passed
@thevuong
thevuong deleted the feat/tracking-tcf-gpp-reader branch July 18, 2026 10:13
thevuong added a commit that referenced this pull request Jul 18, 2026
…plemented (#613)

Follow-up to #612. The TCF/GPP interop reconciler is now shipped, so the
`spec-ad-consent-frameworks` status line is stale.

## Change (spec-only, non-normative)
- **Status line** — "design target … not yet implemented" → notes that
`@codefast/tracking` ships `reconcileAdFrameworkConsent` +
`hasTcfApi`/`hasGppApi` (§3, vectors V1–V6), while deriving the
`CmpConsentSignal` from a live CMP's `TCData`/GPP remains ad-ops policy
the integrator supplies (§5).
- **spec CHANGELOG 1.0.2** — status refresh (patch). The §3 contract and
vectors are unchanged.

## Note (not in this PR)
Other spec status lines are now similarly out of date after this
session's implementation work and could be refreshed in a follow-up:
`spec-destinations` §5 ("not yet implemented" — Meta/TikTok/UET
references now shipped), `spec-consent-receipts` (signing + durable
store shipped), `spec-data-subject-rights` ("partially met" — GA4
deletion + `onErasure` now shipped). Left out to keep this PR scoped.

`oxfmt` clean; no code change.
thevuong added a commit that referenced this pull request Jul 20, 2026
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.

⚠️⚠️⚠️⚠️⚠️⚠️

`main` is currently in **pre mode** so this branch has prereleases
rather than normal releases. If you want to exit prereleases, run
`changeset pre exit` on `main`.

⚠️⚠️⚠️⚠️⚠️⚠️

# Releases
## @codefast/tracking@1.0.0-canary.7

### Minor Changes

- [#605](#605)
[`cb46bdd`](cb46bdd)
Thanks [@thevuong](https://github.com/thevuong)! - Adds the shared
ad-destination frame for consuming one `{ ads, analytics }` decision
across ad platforms (spec-destinations §5): `toAdConsentState(decision)`
normalizes it to the two independent levers — `analytics` drives whether
events transmit, `ads` drives Limited Data Use — so per-vendor mappings
cannot drift. Ships a reference Meta destination
(`createMetaDestination`, `toMetaDataProcessingOptions`) that maps each
event and the live `ads` decision to Meta's `dataProcessingOptions`
(geolocated LDU when `ads` is denied) and hands it to an injected
`transport`. Consent-restriction mapping only — the Pixel/CAPI transport
and credentials are the integrator's to supply; an ad sink is never
`exempt`.

- [#612](#612)
[`1337fc3`](1337fc3)
Thanks [@thevuong](https://github.com/thevuong)! - Adds the TCF/GPP
interop reconciler (spec-ad-consent-frameworks): the system **reads** an
external CMP and reconciles it with the native `{ ads, analytics }`
decision — it never becomes a CMP or mints TC/GPP strings.
`reconcileAdFrameworkConsent({ native, cmp,
hasGlobalPrivacyControlSignal })` applies the §3 precedence (a governing
CMP overrides its categories; fail-closed to denied while the CMP is
loading; a missing or out-of-scope CMP leaves native standing; GPC only
tightens `ads`), covering conformance vectors V1–V6.
`hasTcfApi`/`hasGppApi` detect the `__tcfapi`/`__gpp` read APIs without
invoking them. TCF purpose ids and the Google vendor id are deliberately
**not** hard-coded — that mapping is ad-ops policy, so the caller
derives the `CmpConsentSignal` it passes in.

- [#615](#615)
[`2bcd31f`](2bcd31f)
Thanks [@thevuong](https://github.com/thevuong)! - Let `codefast mirror`
generate `package.json#exports` from `dist/`, the same as every other
library package (di, theme), instead of hand-curating them under
mirror's preserve mode. The per-module build output is unchanged, so
mirror emits a subpath for each built module, and the root becomes the
client entry.

  Breaking:

- The root `@codefast/tracking` is now the **client entry** — it
re-exports the isomorphic core plus the whole browser surface
(`createClientTracker`, `createConsentRuntime`, the React bindings, the
gtag + ad-network destinations). Server code must import the core it
needs from `@codefast/tracking/core/*`, not from the root.
- The `./client`, `./server`, `./core`, `./react`, and `./destinations`
group barrels are gone. Use the client root for browser code, or a
module's own subpath for granular/server imports
(`@codefast/tracking/server/initial-consent`,
`@codefast/tracking/client/gpc`, …).
- The TanStack Start adapter is now
`@codefast/tracking/adapters/tanstack-start` (was `/tanstack-start`);
the import-protection deny-list is
`@codefast/tracking/tooling/import-protection` (was
`/import-protection`). `SERVER_ONLY_SUBPATHS` now denies `server/**` and
`adapters/**`.

- [#617](#617)
[`b979371`](b979371)
Thanks [@thevuong](https://github.com/thevuong)! - Harden the package
from a full audit — correctness, coverage, and a leaner public surface.

- **`isConsentReceiptInput` now validates `method` and `subjectIdType`
against their enums**, not just `typeof === "string"` — the
untrusted-body guard no longer narrows a bogus value to a closed union
member.
- **`CookieAnonymousId` gains `current()`** — a non-minting read of the
existing id (`undefined` when none) so a consent receipt stamps the id
the visitor already carries instead of a throwaway that never correlates
for erasure.
- **`coarsenIp` rejects out-of-range IPv4 octets** (`"999.…"`) rather
than storing a malformed coarse value.
- **Microsoft UET consent routes through the shared `toAdConsentState`
ad lever**, so its `ad_storage` mapping can't drift from Meta/TikTok.
- **Dropped unused foreign type re-exports** so each type has one home:
`InitialConsent` no longer re-exported from `adapters/tanstack-start` or
`server/initial-consent` (import it from `core/consent`), and the
`AnonymousIdResponseCookieOptions` alias is gone —
`setAnonymousIdResponseCookie` takes `AnonymousIdCookieOptions` from
`server/anonymous-id-cookie` directly.
- **Collapsed the `TrackedEvent` envelope to a single interface** — the
unused `TrackedEventBase` and `TrackEvent` names are gone
(`TrackedEvent` keeps the `type: "track"` discriminant for a future
additive union).

Also adds test coverage for the previously-untested
`recordConsentReceiptFromRequest` adapter path (no-store header, body-IP
rejection, coarsened IP, PII-free ack).

- [#617](#617)
[`b979371`](b979371)
Thanks [@thevuong](https://github.com/thevuong)! - Remove two leftover
indirection layers in the server lane that no call site used.

  Breaking:

- The `@codefast/tracking/adapters/request-context` subpath is gone. Its
`RequestContext` seam (a `getHeader`/`setHeader` interface) plus the
parallel `*FromContext`/`*OnContext` helpers existed only to back a
hypothetical future `./next`/`./remix` adapter, but there was exactly
one adapter and it duplicated every signature and doc comment.
`@codefast/tracking/adapters/tanstack-start` now calls
`getRequestHeader`/`setResponseHeader` directly; its public surface
(`resolveInitialConsentFromRequest`, `setAnonymousIdResponseCookie`,
`clearAnonymousIdResponseCookie`, `recordConsentReceiptFromRequest`) is
unchanged.
- `resolveRegion(headers)` is removed from
`@codefast/tracking/server/region`. It was a pre-fail-closed leftover
with no production call site, and its missing-geo semantics (unknown
region → opt-out) contradicted the fail-closed invariant the
server-first path relies on. Use `resolveRegionFromCountryCode` (what
the production path already uses via `resolveInitialConsent`), or
`resolveInitialConsentFromRequest` for the full per-request resolution.

- [#606](#606)
[`5a4ff42`](5a4ff42)
Thanks [@thevuong](https://github.com/thevuong)! - Adds
`createDurableReceiptStore({ backend })` — a durable `ReceiptStore` over
an injected `ReceiptStoreBackend` (a minimal id-keyed `get`/`put`
primitive). The package supplies the append-only contract and
adaptation; the deployment supplies the backend client (Vercel KV,
Postgres, an append-only log), so no database dependency is baked in.
`put` MUST be idempotent-by-id so the append-only guarantee holds
atomically under retries/concurrency (e.g. KV set-if-absent, Postgres
`INSERT … ON CONFLICT DO NOTHING`) — the frame delegates rather than
doing a racy get-then-put. Pair it with a real backend in production,
where `createInMemoryReceiptStore` is not a lawful store on its own.

- [#604](#604)
[`7bb4be7`](7bb4be7)
Thanks [@thevuong](https://github.com/thevuong)! - `createClientTracker`
now accepts an optional `isExemptionAllowed` gate, consulted before an
`exempt` destination receives an event while the consent gate is closed.
ePrivacy audience-measurement exemption is jurisdiction-dependent
(spec-destinations §2), so it must be gateable per region rather than
assumed global — returning `false` withholds even exempt sinks where
exemption is not defensible. Omit it to keep the prior behavior (exempt
everywhere). The gate is irrelevant once consent is granted, since every
destination then receives the full envelope.

- [#608](#608)
[`6cdd930`](6cdd930)
Thanks [@thevuong](https://github.com/thevuong)! - Adds GA4 DSR
delegation (spec-data-subject-rights §3): the system delegates
per-visitor deletion to the platform rather than building a deletion
store. `buildGa4UserDeletionRequest({ propertyId, clientId })` returns
the network-free request shape and `submitGa4UserDeletion({ …,
accessToken, transport? })` POSTs it — targeting the current **Analytics
Admin API** `properties.submitUserDeletion` (the legacy v3
`userDeletionRequests:upsert` was sunset with Universal Analytics),
keyed by a flat `clientId`. Authorization is the caller's: pass a bearer
token for the `analytics.edit` scope; no OAuth or HTTP client is baked
in. Server-only (`@codefast/tracking/server`).

- [#617](#617)
[`b979371`](b979371)
Thanks [@thevuong](https://github.com/thevuong)! - Adopt TanStack
Start's first-class server helpers in the adapter instead of
hand-rolling over raw request/response headers.

- The anonymous-id cookie is now written with `setCookie`/`deleteCookie`
(from `@tanstack/react-start/server`) rather than
`setResponseHeader("set-cookie", …)`. The raw header call **replaces**
any existing `Set-Cookie` on the response — it would clobber a session
or framework cookie set on the same response — whereas `setCookie`
appends. No behavior change to the emitted cookie (still `Path=/;
Max-Age=1y; SameSite=Lax; Secure`, not `HttpOnly`).
- The connection IP for consent receipts is now read with
`getRequestIP({ xForwardedFor: true })` — the maintained, platform-aware
path — instead of hand-parsing `x-forwarded-for`/`x-real-ip`.

Breaking (`@codefast/tracking/server/anonymous-id-cookie`): the string
builders `buildAnonymousIdSetCookie`/`buildClearAnonymousIdSetCookie`
are replaced by
`resolveAnonymousIdCookie`/`resolveClearAnonymousIdCookie`, which return
the validated name/value plus cookie attributes for a framework
`setCookie`/`deleteCookie` call. `isValidAnonymousId` is unchanged; the
cookie-name guard is now the exported
`assertValidAnonymousIdCookieName`.

- [#607](#607)
[`749dd16`](749dd16)
Thanks [@thevuong](https://github.com/thevuong)! - Adds server-side GA4
Measurement Protocol primitives for forwarding a **server-owned** event
(re-added now that a consumer tracks one — a server-recorded consent
decision): `sendMeasurementProtocolEvents` POSTs `{ client_id, events,
consent? }` to the credentialed `/mp/collect` endpoint through an
injected `transport` (default `fetch`), so no HTTP client or credentials
are baked in; `extractGaClientId` derives the GA4 `client_id` from a
`_ga` cookie; `toMeasurementProtocolConsent` maps the package
`ConsentDecision` to the MP `consent` signals. Server-only
(`@codefast/tracking/server`). The caller owns the credentials and the
consent gate.

- [#602](#602)
[`5ca04e2`](5ca04e2)
Thanks [@thevuong](https://github.com/thevuong)! - `createClientTracker`
now accepts an optional `onDeliveryError` hook, called once per failed
delivery (a destination throwing synchronously or rejecting) with `{
destination, error, event }`. The tracker still swallows the failure so
tracking never breaks the interaction — the hook is a metering seam for
wiring delivery failures to a monitor in production. The hook is itself
guarded, so a throwing observer can't break the interaction either.
Exposes the `DeliveryErrorContext` type from
`@codefast/tracking/client`.

- [#610](#610)
[`fdb8d7c`](fdb8d7c)
Thanks [@thevuong](https://github.com/thevuong)! - Adds the
per-destination erasure capability for DSR withdrawal
(spec-data-subject-rights §3, DSR-V2/V4): `Destination` gains an
optional `onErasure(id)` hook, and `createClientTracker` returns an
`erase(id)` method that invokes each destination's `onErasure` once on
withdrawal, swallowing failures so a destination can never break the
flow. The reference `createMetaDestination` implements `onErasure` as
cookie-clear (via an injected `clearCookies` seam) plus stop-send — Meta
exposes no per-visitor deletion API, so the binding never fabricates
one. Destinations with nothing to erase omit the hook.

- [#611](#611)
[`a5527c3`](a5527c3)
Thanks [@thevuong](https://github.com/thevuong)! - Extends the
ad-destination frame with two more reference vendors (spec-destinations
§5), consuming the same `{ ads, analytics }` decision:
`createMicrosoftUetDestination` / `toMicrosoftUetConsent` map `ads` to
UET's only enforced signal, `ad_storage` (UET has no
`analytics_storage`); `createTiktokDestination` / `toTiktokConsent` map
`ads` to TikTok's single `limited_data_use` boolean (not Meta's
`dataProcessingOptions` structure). Both take an injected `transport`
(no pixel id, tag id, or network client baked in) and implement
`onErasure` as cookie-clear + stop-send, since neither exposes a
per-visitor deletion API. `consentRequirement` stays `"required"`.
## @codefast/typescript-config@1.0.0-canary.7

### Minor Changes

-
[`71cf664`](71cf664)
Thanks [@thevuong](https://github.com/thevuong)! - Add a
`library-build.json` preset holding the shared emit options for the
native `tsc` package build (`noEmit: false`, `declaration`,
`declarationMap`, `sourceMap`, `types: ["node"]`). Each package's
`tsconfig.build.json` now uses array `extends` (`["./tsconfig.json",
"@codefast/typescript-config/library-build.json"]`) and keeps only its
local `outDir`/`rootDir` and `include` — dropping the duplicated emit
block across all library packages. Path-relative options stay local
because `extends` resolves them against the file that declares them.
## @codefast/ui@1.0.0-canary.7

### Patch Changes

- [#632](#632)
[`44035ca`](44035ca)
Thanks [@thevuong](https://github.com/thevuong)! - Annotate
`CheckboxGroupItem`'s `onCheckedChange` callback with a `CheckedState`
type derived from the underlying Radix primitive. Radix's `radix-ui`
umbrella namespace merge dropped the contextual type for the inline
callback parameter, tripping `noImplicitAny` during the build; deriving
the type from `Root` keeps it in lockstep with the primitive.

- Updated dependencies []:
  - @codefast/tailwind-variants@1.0.0-canary.7
## @codefast/cli@1.0.0-canary.7


## @codefast/di@1.0.0-canary.7


## @codefast/tailwind-variants@1.0.0-canary.7


## @codefast/theme@1.0.0-canary.7


## @codefast/benchmark-di-inversify@1.0.0-canary.7

### Patch Changes

- Updated dependencies []:
  - @codefast/benchmark-harness@1.0.0-canary.7
  - @codefast/benchmark-viewer@1.0.0-canary.7
  - @codefast/di@1.0.0-canary.7
## @codefast/benchmark-tailwind-variants@1.0.0-canary.7

### Patch Changes

- Updated dependencies []:
  - @codefast/benchmark-harness@1.0.0-canary.7
  - @codefast/benchmark-viewer@1.0.0-canary.7
  - @codefast/tailwind-variants@1.0.0-canary.7
## @codefast/benchmark-viewer@1.0.0-canary.7

### Patch Changes

- Updated dependencies []:
  - @codefast/benchmark-harness@1.0.0-canary.7
  - @codefast/tailwind-variants@1.0.0-canary.7
## @codefast/benchmark-harness@1.0.0-canary.7

Co-authored-by: Vuong Phan <13298232+thevuong@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant