Skip to content

Preserve Prebid EID fidelity from browser capture to bidder request - #1416

Open
dhruv8sh wants to merge 27 commits into
mainfrom
spec/1246-prebid-eid-fidelity
Open

dhruv8sh wants to merge 27 commits into
mainfrom
spec/1246-prebid-eid-fidelity

Conversation

@dhruv8sh

@dhruv8sh dhruv8sh commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Carries the design spec, the plan and the implementation for Write Lockr IDs to KV store #1246. The implementation was reviewed as Preserve Prebid EID fidelity from browser capture to bidder request #1415 and merged into this branch, so this PR now lands all three on main.
  • The trustedServer Prebid adapter forwards exactly the EIDs Prebid placed in its own bidder request (ortb2.user.ext.eids and user.eids), with their OpenRTB 2.6 inserter, matcher, mm and ext. It no longer reads pbjs.getUserIdsAsEids(), which leaked IDs restricted to other bidders or redacted for trustedServer (D1) and missed every ortb2 EID, such as Lockr's ID5 (D2, Write Lockr IDs to KV store #1246).
  • The ts-eids writer moved into buildRequests, so the cookie copies each request's set (without EID-level ext) before the request is sent. Where GDPR applies, it is written only with TCF Purpose 1 and Purpose 4 consent, the rule the server applies before it uses or stores these EIDs, and a tcf2Enforcement listener clears it when Prebid's TCF rules block trustedServer. The server validates body and cookie with one parser that drops only the bad entry or field, forwards browser entries as sent with KV filling only absent registered sources, and ingests into KV on /auction only the client set the auction used. TS stops overwriting the publisher's Prebid userSync settings (D4).

ts-eids and x-ts-eids both stay.

Changes

File Change
docs/superpowers/specs/2026-10-18-prebid-eid-fidelity-design.md Design spec: defects D1 to D9, architecture, privacy and timing models, decisions, rollout, and a review log of five agent rounds
docs/superpowers/plans/2026-10-18-prebid-eid-capture-fidelity.md Twelve-task TDD implementation plan
docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md Red log: every new test seen failing first, or proven by reverting the line it guards
crates/trusted-server-core/src/openrtb.rs Eid gains inserter, matcher, mm, ext (skipped when absent, byte-identical otherwise) and Eid::new
crates/trusted-server-core/src/ec/client_eids.rs (new) One parser for browser EIDs: per-element parse isolation, field rules and caps, dedupe before the 64-entry cap, value-free debug logs
crates/trusted-server-core/src/auction/formats.rs, auction/endpoints.rs AdRequest.eids captured as raw JSON; body and cookie go through the shared parser; merge_auction_eids forwards browser entries as sent and adds KV only for absent registered sources
crates/trusted-server-core/src/ec/prebid_eids.rs, ec/admin.rs Structured ts-eids cookie decoded through the shared parser; legacy cookie UIDs validated before browser precedence; admin diagnostics keep raw sources and UIDs
crates/trusted-server-core/src/ec/finalize.rs KV ingestion input chosen by sync source: /auction reads its own client set, other routes the cookie
crates/trusted-server-core/src/{publisher.rs, ec/eids.rs, ec/identify.rs, integrations/*, auction/*} Eid::new call sites; outbound serialization, merge and ingestion tests
crates/trusted-server-adapter-fastly/src/app.rs Adapter-layer test for the raw ts-eids handed to finalization
crates/trusted-server-js/lib/src/integrations/prebid/eids.ts (new) Pure collector (64 KiB UTF-8 budget) and ts-eids value builder (no EID-level ext, 3,072 bytes, UTF-8 base64)
crates/trusted-server-js/lib/src/integrations/prebid/index.ts, src/core/auction.ts buildRequests forwards the bidder request's EIDs and writes or clears the cookie behind the Purpose 1 and 4 check; TCF listener; old collector, cookie sync and installUserIdModules removed
crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.{ts,json} Unused EID-to-module resolver and eidSources removed
crates/trusted-server-js/lib/test/** eids.test.ts; adapter tests with a getUserIdsAsEids canary and paired consent cases; real-Prebid JSDOM suite prebid-eid-fidelity.test.mjs; cross-language fixtures
docs/guide/** Prebid, Lockr, edge cookies, EC setup, API reference and configuration guides, including the full trustedServer TCF exception rule

Closes

Closes #1246

Spec

Spec: docs/superpowers/specs/2026-10-18-prebid-eid-fidelity-design.md (this PR; five agent review rounds in its review log)

What breaks (spec 8.2)

  • Bidders: browser entries come first, KV-only sources after. KV's value and atype are no longer sent for a source the browser sends, and repeated sources are no longer folded.
  • Publishers: TS stops overwriting syncEnabled, filterSettings, syncsPerBidder, syncDelay and auctionDelay, and no longer force-enables iframe syncs. Auctions wait up to auctionDelay (500 ms by default) while IDs are pending.
  • Server-side bidders folded into trustedServer: IDs restricted to them are no longer forwarded.
  • GDPR traffic: without a trustedServer exception the cookie is cleared instead of written from getUserIdsAsEids(); with one, it is written only with Purpose 1 and 4 consent. Body-less routes and KV ingestion lose browser EIDs for the rest of that traffic.
  • Cookie contents: ortb2 EIDs and provenance fields, never EID-level ext, written at request time.
  • KV ingestion on /auction no longer reads the cookie next to the body.
  • Providers: additive fields and possibly repeated sources in user.ext.eids and x-ts-eids.
  • /_ts/admin/eids consumers: new fields in the cookie entries; a mistyped field is dropped instead of failing the whole cookie.

Operator actions (spec 8.3)

  • Before production, send one request with a repeated source and the four new fields to each configured provider in staging and confirm it is accepted.
  • Regenerate external bundles that carry a baked-in shim (ts prebid client).
  • Tell publishers about the userSync change.
  • For sites with GDPR traffic, decide with the publisher whether to grant trustedServer the full TCF exception rule.

Follow-ups (spec 8.5)

Prebid.js identityLink fix for a malformed _lr_env (D7); sharedId cookie ingestion ignoring Prebid restrictions; TS's own TCF identity, with a vendor-level check on the ts-eids write; per-bidder EID forwarding through Prebid Server eidpermissions; refresh rules for browser-sourced KV IDs; restoring autoRefresh after a late managed seed; a server-written cookie; a lenient parse of the rest of the /auction body.

Test plan

  • cargo test-fastly && cargo test-axum (on Preserve Prebid EID fidelity from browser capture to bidder request #1415: core 2985 passed, plus test-fastly-reuse, test-cloudflare, test-spin; after the review fixes: test-fastly, core 3015 passed)
  • cargo clippy-fastly && cargo clippy-axum (all eight aliases on Preserve Prebid EID fidelity from browser capture to bidder request #1415; clippy-fastly after the review fixes)
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run (1258 passed, Node 24.12.0), including the real-Prebid suite
  • JS format and lint: npm run format && npm run lint
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: parity test, template_build_digest, scripts/lint-wasm-clock.sh, scripts/test-cli.sh on Preserve Prebid EID fidelity from browser capture to bidder request #1415
  • Other: staging provider check (repeated source and the four new fields per provider)
  • Other: live check through ts dev proxy on a page with Lockr and the default User ID preset (spec 7.6)

Checklist

  • Feature PRs: spec linked above, ending in a review log of two or more agent review rounds
  • Changes follow AGENTS.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses log macros per AGENTS.md (not println!)
  • New code has tests
  • No secrets or credentials committed

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Eid gains inserter, matcher, mm and ext, each skipped when absent, so serialization without them is byte-identical. Eid::new replaces the struct literals that never carry these fields.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
ec::client_eids parses each array element on its own, so one malformed entry or field costs only itself. It validates sources, UIDs, atype, EID-level provenance fields and ext sizes, drops duplicates before the 64-entry cap, and logs field names and sources but never values.
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
AdRequest captures eids as raw JSON so a lone surrogate in one entry no longer rejects the whole /auction body. The ts-eids structured branch uses the same parser, so a mistyped field or bad entry costs only itself and the body's caps apply to the cookie. Admin diagnostics still report raw sources and UIDs.

The end-to-end fixture tests land with the merge rewrite, which they also depend on.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Browser entries reach providers unchanged, in wire order, with repeated sources kept apart. KV adds entries only for registered sources the browser did not send, so its stale UID and operator atype no longer override the browser's.

Includes the end-to-end TSJS fixture tests deferred from the shared-parser change, since they depend on this merge keeping EID-level fields.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Finalization chooses its browser input by sync source. /auction reads the client set the auction used, so a cookie from an earlier page is not ingested next to a scoped body. Other routes keep reading ts-eids, and the sharedId cookie is still applied last.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
collectBidderEids copies ortb2.user.ext.eids and user.eids as plain data within a 64 KiB UTF-8 budget. fitEidCookie builds the ts-eids value without EID-level ext, trimmed to 3,072 bytes and encoded as UTF-8 base64.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
…buildRequests

buildRequests reads bidderRequest.ortb2 instead of getUserIdsAsEids(), so IDs restricted to other bidders or redacted for trustedServer are no longer forwarded, and ortb2 EIDs such as Lockr's ID5 now arrive with their EID-level fields. The cookie copies each request's set before it is sent and is cleared when Prebid's TCF rules block trustedServer. bidsBackHandler no longer touches it.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
The served shim measured 42,144 characters after it gained the bidder-request EID collector, ts-eids cookie fitting and the tcf2Enforcement listener, so the bound moves from 41,000 to 42,500.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
installUserIdModules forced auctionDelay 0, syncDelay 3000, syncsPerBidder 5 and iframe syncs for every bidder on window.load. Prebid accumulates userSync settings, so every later auction ran without waiting for pending IDs. TS now writes only managed userIds and, on a late seed, autoRefresh.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
A JSDOM suite evaluates the generated bundle and the served shim and captures /auction bodies. It covers a bidder-restricted ID, same-source entries with their own fields, the first auction after window.load, and TCF with and without the full trustedServer exception. Every arm except the timing control fails against main.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
D1, D3 and both TCF arms now assert that document.readyState is still loading after install and after the auction, so each proves it ran before window.load as spec 7.5 requires.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Only tests read resolvePrebidUserIdModulesFromEids and the registry's eidSources. The data could not be made correct, since several modules share sources. The CLI and bundle builder read only module and config names.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Guides now describe forwarding the trustedServer bidder request's EIDs, the browser-first merge, per-entry validation, KV ingestion inputs by route, TS no longer setting userSync, and the full trustedServer TCF rule with its warning.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Guard the cookie clear so a blocked document.cookie costs only the cookie, and cover the nothing-fits clear path and both failure paths with adapter tests.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Admin diagnostics lost the parse error for cookies that decode to a non-array JSON value. Routing is unchanged. Record the final review fixes in the red log.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
@dhruv8sh dhruv8sh self-assigned this Oct 19, 2026
@dhruv8sh dhruv8sh linked an issue Oct 19, 2026 that may be closed by this pull request
@aram356
aram356 requested a review from jevansnyc October 19, 2026 15:56
@aram356 aram356 changed the title Add design spec and plan for Prebid EID fidelity Add design spec and plan for Prebid JS EID fidelity Oct 19, 2026

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on two correctness concerns in the EID fidelity work:

  • Gate browser-side EID cookie persistence on storage consent, clearing the cookie on denial while preserving forwarding.
  • Validate legacy-cookie UIDs before applying browser precedence so invalid IDs cannot reach providers or suppress valid KV identities.

Please add regression coverage for both cases. The inline comments also include two non-blocking cleanups: import grouping and borrowing temporary raw EID elements instead of allocating copies.

Comment thread crates/trusted-server-js/lib/src/integrations/prebid/index.ts
Comment thread crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts
Comment thread crates/trusted-server-core/src/auction/endpoints.rs
Comment thread crates/trusted-server-core/src/ec/client_eids.rs Outdated
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The EID fidelity changes preserve bidder-scoped entries and provenance through the browser and server paths, but two blocking correctness concerns remain at commit 5ab7d4994fca2e78beab1cba1efa426c31588076.

Blocking

  • 🔧 [P1] Gate browser EID cookie persistence on storage consent. The shim writes the one-day ts-eids cookie whenever bidder EIDs exist, without checking storage permission. Transmission permission does not establish storage permission, and the TCF listener checks only blocked bidders/EIDs. Gate persistence on usable storage consent, clear on denial, and add paired regression coverage while preserving permitted forwarding. See the existing inline finding.
  • 🔧 [P2] Validate legacy cookie UIDs before applying browser precedence. The legacy decoder accepts whitespace-only and oversized IDs, while the revised merge trusts nonempty UID vectors. Such entries can reach providers and suppress a valid KV identity for the same source. Validate legacy UIDs before constructing the browser-source set and cover whitespace and 513-byte IDs with a valid KV fallback. See the existing inline finding.

These findings reference the existing inline threads to avoid duplicate comments. No replacement suggestions are included because the fixes also require regression coverage.

CI Status

All 23 reported checks pass. Local tests were not rerun; this review uses the reported remote CI results.

  • integration tests: PASS
  • browser integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • CodeQL: PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • format-docs: PASS (required)
  • cargo test (cross-adapter parity): PASS
  • Analyze (actions): PASS
  • vitest: PASS
  • cargo fmt: PASS (required)
  • cargo test (axum native): PASS
  • cargo test: PASS (required)
  • format-typescript: PASS (required)
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • Analyze (javascript-typescript): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • docs-proposal scripts: PASS
  • CLAUDE.md symlink guard: PASS
  • prepare integration artifacts: PASS
  • Analyze (rust): PASS
  • Analyze (javascript-typescript): PASS
  • Analyze (python): PASS

Prebid lets trustedServer see ortb2 EIDs on any purpose from 2 to 10, so forwarding does not establish storage consent. Where GDPR applies, buildRequests now writes the cookie only with Purpose 1 in the bidder request's gdprConsent and otherwise clears it, still forwarding the EIDs in the /auction body.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
The legacy flattened cookie branch accepted whitespace-only and oversized IDs. Because the merge lets any browser entry displace KV's entry for its source, such an ID reached providers and hid a valid KV identity. Apply the shared UID rule and drop blank sources.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
The input RawValue already owns the JSON text, and the element slices are used only inside each function.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
prk-Jr and others added 2 commits October 22, 2026 13:45
The server uses or stores browser EIDs only with Purpose 1 and Purpose 4 (allows_eid_persistence, gate_eids_by_consent), so a cookie written on Purpose 1 alone held IDs no server path reads. The write now needs both where GDPR applies, and is still unrestricted when gdprApplies is not true, so CMPs that answer outside the EU with an empty purpose record keep the cookie.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
@dhruv8sh dhruv8sh changed the title Add design spec and plan for Prebid JS EID fidelity Preserve Prebid EID fidelity from browser capture to bidder request Oct 22, 2026

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The per-entry parser, the browser-first merge and the /auction ingestion change hold up. The new guards are tightly tested: 19 of 23 Rust mutants and 33 of 44 JS mutants are killed, and the remaining Rust survivors are equivalent or diagnostics-only. Two problems remain in the browser cookie path: the new storage-consent check still lets ts-eids be written in two GDPR cases (a CMP timeout and a GPP-only CMP), and the guide's eidsRequireP4Consent instruction, followed literally, removes the publisher's Purpose 4 EID policy for every bidder. Both were reproduced against real Prebid 10.26.0. The rest is docs and spec drift since the last three commits, test gaps, and smaller cleanups.

6 of the inline comments below carry a one-click GitHub suggestion. Use Commit suggestion, or Add suggestion to batch for several at once, to apply them as commits on the PR branch. The remaining comments describe the fix in prose because the change touches multiple files or lines outside the diff and can't be auto-applied.

Blocking

🔧 wrench

  • The cookie consent check fails open on Prebid's null consent (CMP timeout) and on GPP-only EU CMPs: see inline at crates/trusted-server-js/lib/src/integrations/prebid/index.ts:2852
  • The eidsRequireP4Consent instruction weakens the publisher's EID policy for every bidder: see inline at docs/guide/integrations/prebid.md:1074
  • The guides overstate when the cookie consent check applies: see inline at docs/guide/edge-cookies.md:164 and docs/guide/integrations/prebid.md:882
  • The spec, review log and red log predate the last three design commits: see inline at docs/superpowers/specs/2026-10-18-prebid-eid-fidelity-design.md:3
  • /auction ingestion is misdescribed: see inline at docs/guide/ec-setup-guide.md:196
  • The legacy cookie's blank-source rule is untested, and the legacy branch skips the body's caps: see inline at crates/trusted-server-core/src/auction/endpoints.rs:1913
  • Untested guards in fitEidCookie and hasEidPersistenceConsent: see inline at crates/trusted-server-js/lib/src/integrations/prebid/eids.ts:188
  • Quadratic cookie trim loop on the auction path: see inline at crates/trusted-server-js/lib/src/integrations/prebid/eids.ts:198
  • The autoRefresh claim is too broad: see inline at docs/guide/integrations/prebid.md:343
  • Unneeded onEvent cast: see inline at crates/trusted-server-js/lib/src/integrations/prebid/index.ts:2333
  • Shim size bound has about 1,200 characters of slack: see inline at crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs:316
  • openrtb_atype scope is overstated: see inline at docs/guide/configuration.md:656
  • "Only a malformed body outside eids rejects the request" is overstated: see inline at docs/guide/api-reference.md:237
  • AuctionEid is an alias for unknown: see inline at crates/trusted-server-js/lib/src/core/auction.ts:30
  • Stale test title: see inline at crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts:10
  • isRecord is duplicated: see inline at crates/trusted-server-js/lib/src/integrations/prebid/eids.ts:36

Cross-cutting / body-level findings

  • 🔧 No CHANGELOG.md entry for operator- and publisher-visible changes. Spec 8.3 (:1426-1427) says "The release notes say so", and [Unreleased] already carries Breaking entries for comparable changes. This PR changes behavior that operators and publishers will notice:

    • Trusted Server no longer overwrites the publisher's userSync. Iframe user syncs are no longer enabled for every bidder, and auctions now wait up to Prebid's 500 ms auctionDelay while IDs are pending.
    • Browser EIDs go out first and KV-only sources after. KV's value and atype are no longer sent for a source the browser sends, and a source can repeat.
    • ts-eids is cleared for GDPR traffic without a trustedServer exception, or without Purposes 1 and 4.
    • Sites with GDPR traffic need the full trustedServer TCF exception rule.
    • Bundles with a baked-in shim must be regenerated with ts prebid client.
    • Each provider needs the staging check for repeated sources and the four new fields.

    Please add a Breaking entry.

  • 🔧 The credential-based IdentityLink validation still checks the API the adapter stopped reading. docs/guide/integrations/prebid.md:948 (outside the diff hunks) says "Confirm pbjs.getUserIdsAsEids() reports source liveramp.com". The adapter no longer reads that API (index.ts:2930; the guide says so at :667-668). Under TCF without a trustedServer exception, that step passes while no LiveRamp EID reaches Trusted Server. Point it at the trustedServer bidRequested event's ortb2.user.ext.eids in pbjs.getEvents(), without recording the value, as lockr.md:237-238 already does.

CI Status

  • Analyze (actions): PASS
  • Analyze (javascript-typescript): PASS
  • Analyze (python): PASS
  • Analyze (rust): PASS
  • browser integration tests: PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo fmt: PASS (required)
  • cargo test: PASS (required)
  • cargo test (axum native): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • CLAUDE.md symlink guard: PASS
  • CodeQL: PASS
  • docs-proposal scripts: PASS
  • format-docs: PASS (required)
  • format-typescript: PASS (required)
  • integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • prepare integration artifacts: PASS
  • vitest: PASS

Comment on lines +2852 to +2863
function hasEidPersistenceConsent(bidderRequest: object): boolean {
const { gdprConsent } = bidderRequest as { gdprConsent?: unknown };
if (!isRecord(gdprConsent) || gdprConsent.gdprApplies !== true) {
return true;
}
const { vendorData } = gdprConsent;
if (!isRecord(vendorData) || !isRecord(vendorData.purpose)) {
return false;
}
const { consents } = vendorData.purpose;
return isRecord(consents) && consents[1] === true && consents[4] === true;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: The Purpose 1 and 4 check still lets ts-eids be written in two GDPR cases

hasEidPersistenceConsent treats a gdprConsent without gdprApplies: true, and a missing gdprConsent, as "GDPR does not apply". Two real Prebid 10.26 states reach that branch for EU traffic. I reproduced both in a JSDOM harness with the generated bundle and the served shim, reusing prebid-eid-fidelity.test.mjs:

  1. The CMP stub is present but never answers. On consentManagement.gdpr.timeout, Prebid stores its null consent (libraries/consentManagement/cmUtils.ts:79-87, getNullConsent → toConsentData(null) in modules/consentManagementTcf.ts:154-160,190): { gdprApplies: <defaultGdprScope, false unless set>, apiVersion: 2 }, with no vendorData. With __tcfapi = () => {} and timeout: 200, the trustedServer bidder request carried {"gdprApplies":false,"apiVersion":2}, nothing was blocked, and ts-eids was overwritten. The same page with defaultGdprScope: true blocks trustedServer and the listener clears the cookie, so the gap is only the shim reading Prebid's default as an answer.
  2. A GPP-only CMP reports the EU TCF section. consentManagementGpp fills only gppConsent; gdprConsent is attached only from gdprDataHandler (src/adapterManager.ts:700-709). With applicableSections: [2] and every tcfeuv2 purpose denied, gdprConsent was absent and the cookie was written.

The server never uses these EIDs (no EC without Purpose 1 in a GDPR jurisdiction, and effective_tcf reads the GPP EU section). What remains is a one-day, script-readable device write without consent: the same class as the earlier Purpose 1 thread, and it makes "written only with Purpose 1 and 4 consent" untrue. A CMP that is absent altogether (for example one loaded only for EU visitors) leaves gdprConsent unset (cmUtils.ts:97-100), so failing closed on these two shapes costs no non-EU traffic.

Proposed fix (apply manually: it changes this function and test/integrations/prebid/index.test.ts, so it can't be a single suggestion):

function hasEidPersistenceConsent(bidderRequest: object): boolean {
  const { gdprConsent, gppConsent } = bidderRequest as {
    gdprConsent?: unknown;
    gppConsent?: unknown;
  };
  if (!isRecord(gdprConsent)) {
    // A GPP-only CMP never fills gdprConsent, but its EU TCF section (2)
    // still governs storage, so fail closed when it applies.
    return !(
      isRecord(gppConsent) &&
      Array.isArray(gppConsent.applicableSections) &&
      gppConsent.applicableSections.includes(2)
    );
  }
  const { vendorData } = gdprConsent;
  // Prebid's null consent after a CMP timeout has no vendorData, and its
  // gdprApplies is only `defaultGdprScope`, so it says nothing about the user.
  if (!isRecord(vendorData)) {
    return false;
  }
  if (gdprConsent.gdprApplies !== true) {
    return true;
  }
  if (!isRecord(vendorData.purpose)) {
    return false;
  }
  const { consents } = vendorData.purpose;
  return isRecord(consents) && consents[1] === true && consents[4] === true;
}

Test changes in the storage consent block of index.test.ts:

  • Add ['the CMP timed out (Prebid null consent)', { gdprApplies: false, apiVersion: 2 }] to the "forwards EIDs but clears ts-eids when %s" rows.
  • Change the "GDPR does not apply" fixture to { gdprApplies: false, vendorData: { gdprApplies: false } }, which is what a CMP returns outside the EU. Today's { gdprApplies: false } is exactly the null-consent shape, so the current test pins the defect.
  • Add a GPP pair:
      it.each([
        ['clears ts-eids when only GPP reports the EU TCF section', [2], undefined],
        ['writes ts-eids when GPP reports only non-EU sections', [7], [storedEid]],
      ])('%s', (_case, applicableSections, expected) => {
        const spec = getAdapterSpec();
        presetTsEidsCookie('preset-value');

        spec.buildRequests(
          trustedServerBidRequests(),
          deepFreeze({
            bidderCode: 'trustedServer',
            auctionId: null,
            ortb2: { user: { ext: { eids: [storedEid] } } },
            gppConsent: { applicableSections },
          })
        );

        expect(
          expected === undefined ? readTsEidsCookie() : decodedTsEidsCookie(),
          'should follow the GPP EU TCF section'
        ).toEqual(expected);
      });

With this patch, index.test.ts and the real-Prebid suite pass (312 tests), eslint and prettier are clean, and both new "clears" cases fail against the current function. A Purpose-level read of gppConsent.parsedSections.tcfeuv2 would be a later refinement; failing closed is the safe default.

Comment on lines +1074 to +1077
With the full rule, `trustedServer` still needs Purpose 2 consent and skips
only the vendor check. A publisher that sets `eidsRequireP4Consent` must also
add the same full rule for `personalizedAds`; without it `trustedServer` gets
bid requests with no EIDs and `ts-eids` is cleared after every auction.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: Followed literally, this paragraph weakens the publisher's EID policy for every bidder

Prebid keys consentManagement.gdpr.rules by purpose with Object.fromEntries, so the last rule listed for a purpose wins (modules/tcfControl.ts:392-398). It also reads eidsRequireP4Consent from the active personalizedAds rule itself (:311). "Add the same full rule for personalizedAds" therefore produces a second rule.

I probed it with Purposes 1 to 3 granted, Purpose 4 denied, and vendor consent for a second bidder. The publisher's own Purpose 4 rule gives eidsBlocked: ["appnexus","trustedServer"]. Appending the full rule as this paragraph says gives eidsBlocked: []: every bidder receives EIDs with Purpose 4 denied. A publisher who already has a basicAds rule (for example with deferS2Sbidders) and appends the snippet above loses that rule the same way. Purpose 2 also accepts legitimate interest (LI_PURPOSES = [2], :126, :208-219).

Suggested change
With the full rule, `trustedServer` still needs Purpose 2 consent and skips
only the vendor check. A publisher that sets `eidsRequireP4Consent` must also
add the same full rule for `personalizedAds`; without it `trustedServer` gets
bid requests with no EIDs and `ts-eids` is cleared after every auction.
With the full rule, `trustedServer` still needs Purpose 2 consent or legitimate
interest and skips only the vendor check. Prebid keeps one rule per purpose,
the last one listed, so add `softVendorExceptions: ['trustedServer']` to an
existing `basicAds` rule instead of listing a second one. The same holds for
`eidsRequireP4Consent`, which is a field of the `personalizedAds` rule. A
publisher that sets it must extend that rule to
`{ purpose: 'personalizedAds', enforcePurpose: true, enforceVendor: true, eidsRequireP4Consent: true, softVendorExceptions: ['trustedServer'] }`
rather than add a second `personalizedAds` rule. A second rule listed after it
drops `eidsRequireP4Consent` for every bidder; one listed before it is
ignored, and `trustedServer` then gets bid requests with no EIDs and `ts-eids`
is cleared after every auction.

One more sentence belongs near the snippet. A publisher on the legacy top-level consentManagement shape (CMP settings with no gdpr key) who adds gdpr: { rules } makes Prebid read only config.gdpr (modules/consentManagementTcf.ts:200), which silently drops their top-level CMP settings. The guide should tell them to move those settings under gdpr.

Comment on lines +164 to +168
set is empty or when Prebid's TCF rules block `trustedServer`. Where GDPR
applies, it writes the cookie only with TCF Purpose 1 (store/access device) and
Purpose 4 (personalized ads) consent, the rule below; without it, the adapter
clears the cookie and still sends the EIDs in the `/auction` body. Auctions that
do not include `trustedServer` leave it unchanged.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: The cookie consent check follows Prebid's consent data, not the visitor's jurisdiction

hasEidPersistenceConsent (index.ts:2852-2856) checks Purposes 1 and 4 only when the bidder request's gdprConsent.gdprApplies is true. Without Prebid GDPR data, which means no consentManagement.gdpr (Trusted Server enables it only for managed User IDs, index.ts:846), the adapter writes the cookie for every visitor, EU included. "Where GDPR applies" reads as a jurisdiction guarantee an operator or DPO would rely on. Spec 4.3 already states the condition precisely. The same wording sits in prebid.md:882-884, which has its own suggestion.

Suggested change
set is empty or when Prebid's TCF rules block `trustedServer`. Where GDPR
applies, it writes the cookie only with TCF Purpose 1 (store/access device) and
Purpose 4 (personalized ads) consent, the rule below; without it, the adapter
clears the cookie and still sends the EIDs in the `/auction` body. Auctions that
do not include `trustedServer` leave it unchanged.
set is empty or when Prebid's TCF rules block `trustedServer`. When Prebid's
consent data for the request reports `gdprApplies: true`, it writes the cookie
only with TCF Purpose 1 (store/access device) and Purpose 4 (personalized ads)
consent, the rule below; without it, the adapter clears the cookie and still
sends the EIDs in the `/auction` body. When Prebid has no GDPR data for the
request (no `consentManagement.gdpr`, or no CMP on the page), the adapter
writes the cookie and the server's gate below decides whether it is used.
Auctions that do not include `trustedServer` leave it unchanged.

Comment on lines +882 to +884
With such an exception but without TCF Purpose 1 (store/access device) and
Purpose 4 (personalized ads) consent, the adapter still forwards the EIDs in the
`/auction` body but clears `ts-eids` instead of writing it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: Same consent-condition wording as edge-cookies.md:164. The check applies to Prebid's GDPR consent data for the request, and without that data the adapter writes ts-eids.

Suggested change
With such an exception but without TCF Purpose 1 (store/access device) and
Purpose 4 (personalized ads) consent, the adapter still forwards the EIDs in the
`/auction` body but clears `ts-eids` instead of writing it.
With such an exception but without TCF Purpose 1 (store/access device) and
Purpose 4 (personalized ads) consent in Prebid's GDPR consent data, the adapter
still forwards the EIDs in the `/auction` body but clears `ts-eids` instead of
writing it. Without Prebid GDPR data for the request, the adapter writes
`ts-eids`, and Trusted Server applies its own Purpose 1 and 4 gate before using
it.

@@ -0,0 +1,1617 @@
# Prebid EID fidelity

Status: Reviewed. Round 5 found nothing blocking (see the review log). Backwards

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: The spec, its review log and the red log predate the last three design commits

d51bbeced and 9b901f237 added the cookie consent gate, and 88725f4d6 changed legacy cookie validation, but only 4.3, 4.8, 8.2 and 8.5 were updated. forwardBidderEids (index.ts:2947-2951) now clears the cookie without consent even when the set is non-empty, and these sections contradict that:

  • G2 (:244-247) and A7 (:1501-1504) say the cookie is cleared only "when the set is empty or Prebid's TCF rules block trustedServer". Add "or, where Prebid reports gdprApplies: true, TCF Purpose 1 or 4 is not consented".
  • 4.2 step 3 (:612-614) says "If eids is undefined, it clears ts-eids. Otherwise it writes ts-eids".
  • 4.6 (:829-830) says Lockr's ID5 entry "reaches the body and the cookie" unconditionally.
  • The 4.11 table has no row for "GDPR applies without Purposes 1 and 4 in gdprConsent | EIDs sent in the body; cookie cleared before the request".
  • 4.12 (:1006-1008) omits the 'cleared: no consent' decision the code logs.
  • 7.3 and 7.5 don't list the storage-consent unit cases or the real-Prebid "Purpose 1 denied" arm.

Two smaller stale points:

  • 4.4 step 1 (:680) says Vec<Box<RawValue>>, but 328dd2c17 borrows Vec<&RawValue>.
  • 4.6 (:807) says "Any other source, or none: the ts-eids cookie", but a returning EC with no sync source ingests nothing (ec/finalize.rs:83-88).

Process gaps:

  • Line 3 says "Round 5 found nothing blocking", yet the design changed after Round 5, and Round 4 (:1589-1590) still calls the missing Purpose 1 check a follow-up. CONTRIBUTING (:85-101) asks for one log entry per round, and for rounds until one finds nothing blocking, so this needs a Round 6 entry covering the three commits.
  • The red log (implementation-notes/2026-10-18-prebid-eid-fidelity.md:188-235) has no entries for the storage consent it.each cases, the real-Prebid "Purpose 1 denied" arm, or invalid_legacy_cookie_uids_neither_reach_providers_nor_displace_kv, which A12 (:1518) requires.
  • The plan's hand-off text (plans/2026-10-18-prebid-eid-capture-fidelity.md:5444, :5447) still says the cookie is written "without a Purpose 1 check of its own" and lists "gating the cookie write on Purpose 1" as a follow-up.

Comment on lines +655 to +658
`openrtb_atype` sets the `atype` of EIDs that Trusted Server builds from KV.
It applies only where KV supplies a source the browser did not send; for a
source in the browser's EIDs, the browser's own entry and `atype` are
forwarded instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: openrtb_atype still sets the atype that /identify returns (ec/identify.rs:132), so "applies only where KV supplies a source the browser did not send" is too broad. Start the second sentence with "In auction requests, it applies only where…".

Comment on lines +236 to +237
Each entry is validated on its own. A malformed entry or field is dropped and
the request proceeds; only a malformed body outside `eids` rejects the request.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: Raw capture isolates semantic faults, not syntax. A body whose eids contains a raw control character, an invalid \u hex escape or unbalanced brackets is still rejected as a whole (client_eids.rs:61-63 captures each element without decoding escapes, but serde_json still checks syntax). Suggested wording: "Each entry is validated on its own. A malformed entry or field, including one that fails to parse on its own such as a lone surrogate escape, is dropped and the request proceeds; a body that is not well-formed JSON, or that is malformed outside eids, is rejected."

* request as plain data and the server validates each one, so the server
* defines the contract.
*/
export type AuctionEid = unknown;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: AuctionEid is now an alias for unknown, used only in this file (:36, :72) and not re-exported from src/index.ts or core/index.ts. A named alias for unknown suggests a contract that doesn't exist. Inline it as eids?: unknown[] and keep this doc comment on the field.

});
});

it('exposes config names for modules that do not map EID sources', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: With eidSources gone, no module maps EID sources, so this title describes a distinction that no longer exists. Suggested title: 'exposes config names for every registered module'.


const utf8Encoder = new TextEncoder();

function isRecord(value: unknown): value is Record<string, unknown> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: isRecord now exists twice, here and at index.ts:250, and tcfBlocksTrustedServer (index.ts:2963-2974) re-implements the same object check inline. Export it from one place (this module, or a small shared helper) and reuse it in index.ts.

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The PR's own changes are unchanged since the previous review at 9b901f2; the two new commits merge main. One new problem comes from that merge: the required cargo test check now fails because two tests each install a process-wide test logger. The Rust core changes I re-read (the shared parser, the browser-first merge, /auction ingestion, the new Eid fields) raised no new concerns. The earlier review's findings are all still open.

Blocking

🔧 wrench

  • Two tests install a global test logger, so the required cargo test check fails: see inline at crates/trusted-server-core/src/ec/client_eids.rs:272

Cross-cutting / body-level findings

  • 🔧 Required check cargo test fails on 88cc505. publisher::tests::html_finalization_client_cancellation_drops_work_and_warns_for_auction_once (added on main by #1290, merged in here) fails with left: Some(0), right: Some(1). The cause and fix are in the inline comment. The panic aborts the whole Viceroy test binary, so CI ran none of the core tests after it. Locally, skipping that one test, the rest of the core suite passes (3,087 passed, 0 failed), so nothing else is hiding behind the abort.
  • 📝 The previous review's findings are still open. All 17 inline threads from the 9b901f2 review are unresolved with no reply, and the code they point at is unchanged. Two remain the most consequential: hasEidPersistenceConsent still treats a missing gdprConsent, or gdprApplies !== true, as "GDPR does not apply", so ts-eids is written after a CMP timeout or with a GPP-only CMP. And the guide's eidsRequireP4Consent instruction, followed literally, drops the publisher's Purpose 4 EID rule for every bidder. The two body-level items, a CHANGELOG.md Breaking entry and the IdentityLink validation step, are also still open.

CI Status

  • cargo test: FAIL (required)
  • cargo fmt: PASS (required)
  • format-docs: PASS (required)
  • format-typescript: PASS (required)
  • Analyze (actions): PASS
  • Analyze (javascript-typescript): PASS
  • Analyze (python): PASS
  • Analyze (rust): PASS
  • browser integration tests: PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo test (axum native): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • CLAUDE.md symlink guard: PASS
  • CodeQL: PASS
  • docs-proposal scripts: PASS
  • HTML EOF runtime (Fastly): PASS
  • integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • prepare integration artifacts: PASS
  • vitest: PASS


fn install_capturing_logger() {
INSTALL_LOGGER.call_once(|| {
log::set_logger(&CapturingLogger).expect("should install the capturing test logger");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: Two tests install a global test logger, so the required cargo test check fails

log::set_logger succeeds once per process. This test module installs CapturingLogger. publisher::tests::html_finalization_client_cancellation_drops_work_and_warns_for_auction_once, added on main by #1290 and merged into this branch in 88cc505, installs its own CancellationLogger with let _ = log::set_logger(&LOGGER) and then expects exactly one cancellation warning. Under Viceroy the core tests run one at a time in name order, and ec::client_eids sorts before publisher, so this logger always wins. The publisher test then counts zero warnings:

assertion `left == right` failed: should preserve exactly one existing auction cancellation warning
  left: Some(0)
 right: Some(1)

Reproduced locally at 88cc505 with cargo test -p trusted-server-core --lib --target wasm32-wasip1:

  • The publisher test alone passes.
  • ec::client_eids alone passes (15 tests).
  • Both together abort as in CI.

On native multithreaded runs the order is random, and this .expect can panic instead.

Proposed fix (apply manually: it touches test_support.rs, this module and the publisher.rs test, so it can't be a single suggestion). Install one logger for the whole crate's tests and send records to a per-thread buffer that a test switches on:

// crates/trusted-server-core/src/test_support.rs, inside `#[cfg(test)] mod tests`
static INSTALL: std::sync::Once = std::sync::Once::new();

thread_local! {
    static CAPTURED: std::cell::RefCell<Option<Vec<String>>> =
        const { std::cell::RefCell::new(None) };
}

struct ThreadCaptureLogger;

impl log::Log for ThreadCaptureLogger {
    fn enabled(&self, _: &log::Metadata<'_>) -> bool {
        true
    }
    fn log(&self, record: &log::Record<'_>) {
        CAPTURED.with(|captured| {
            if let Some(lines) = captured.borrow_mut().as_mut() {
                lines.push(record.args().to_string());
            }
        });
    }
    fn flush(&self) {}
}

/// Runs `f` and returns the log lines it emitted on this thread.
pub(crate) fn capture_logs<R>(f: impl FnOnce() -> R) -> (R, Vec<String>) {
    INSTALL.call_once(|| {
        log::set_logger(&ThreadCaptureLogger).expect("should install the test logger");
        log::set_max_level(log::LevelFilter::Debug);
    });
    CAPTURED.with(|captured| *captured.borrow_mut() = Some(Vec::new()));
    let result = f();
    let lines = CAPTURED.with(|captured| captured.borrow_mut().take().unwrap_or_default());
    (result, lines)
}

Then this test wraps parsed_json(...) in capture_logs, and the publisher test counts the lines that start with Dispatched server-side auction dropped without collection. Both stop depending on test order, on Viceroy and natively.

This branch has not been deployed

No deployments
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.

Write Lockr IDs to KV store

4 participants