Skip to content

Collect server-side identity provider EIDs in a single page-load write - #1487

Open
dhruv8sh wants to merge 74 commits into
mainfrom
feat/s2s-eid-collection-core
Open

dhruv8sh wants to merge 74 commits into
mainfrom
feat/s2s-eid-collection-core

Conversation

@dhruv8sh

@dhruv8sh dhruv8sh commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a server-side identity provider framework so Trusted Server can collect provider EIDs (extended IDs) on publisher navigations instead of depending only on browser-side ID modules, which ad blockers and browser privacy features increasingly strip.
  • Stores provider EIDs at full fidelity (source, all UIDs, atype, ext) and serves them unchanged into OpenRTB and Prebid, so bidders see the same shape the provider returned.
  • Collapses every browser-driven Edge Cookie KV write into a single page-load write, so provider lookups, partner syncs and consent changes share one write per navigation and stay inside the KV write budget.
  • Adds a second, after-response collection phase for providers that are too slow for the page budget; their results land in one follow-up write.

This PR registers no provider kinds yet, so none of the provider paths run in production until the vendor PR lands. Follow-up PRs stack on this branch: mirror/pull sync (#1489), then the per-vendor provider implementations (#1488).

Changes

Area Change
ec/eid_types.rs, ec/kv_types.rs, ec/kv.rs, openrtb.rs, ec/prebid_eids.rs Full-fidelity provider EID storage and serving
ec/write_plan.rs, ec/page_load.rs, ec/finalize.rs, publisher.rs One page-load write per browser navigation, built from a write plan
ec/provider.rs, ec/coordinator/*, ec/registry.rs, settings.rs, config.rs, new trusted-server-identity-providers crate, adapter app.rs / main.rs Provider config, validation and a coordinator that runs provider lookups concurrently on publisher navigations within the request deadline
ec/coordinator/after_response.rs, adapter main.rs After-response collection phase and its follow-up write
docs/guide/configuration.md, docs/guide/edge-cookies.md, docs/guide/integrations/prebid.md, trusted-server.example.toml ec.identity_providers configuration docs and example
docs/superpowers/specs/2026-10-30-s2s-eid-collection-design.md Design spec

Collection

  • Two phases. collect = "page" (default) runs before the response within identity_budget_ms (default 200). collect = "after_response" runs after the response is sent within identity_after_response_budget_ms (default 2000).
  • After-response safety. The follow-up phase runs only when the EC existed before this navigation and its row, read after the response, is live and consented. It re-reads the row just before writing and guards the write with that read's generation, so a write from another request in between is not lost.
  • Consent. Lookups need EC consent and EID persistence consent. tcf_vendor_id, when set, is checked only where GDPR applies or a TCF string is present. A KV read failure skips every provider. When a provider's vendor consent is denied, its stored IDs are also withheld from bidders, x-ts-eids and /identify.
  • Refresh state. A provider only sees, and only refreshes, UIDs it wrote itself. Refresh times at or before now are dropped, and generated IDs are kept only for due sources with no live UID.
  • Opt-out memory. A provider opt-out is remembered for 30 days in a new opt_outs field on the row, and the provider is not called again until it lapses. Older builds ignore the field.
  • Rate limits. rate_limit_per_hour is per EC hash, so clearing cookies does not reset it. On Fastly it is enforced per minute with a minimum of one, so values below 60 behave like 60.

Storage and writes

  • Browser IDs never fill provider-owned sources. A source owned by a configured provider is never written from the ts-eids cookie, so an opted-out ID cannot come back from the browser.
  • Batch sync rejects records for provider-owned sources, so batch sync cannot overwrite provider-owned EIDs.
  • UID length limits. Browser, batch and pull ingest keep the 512-byte limit. Provider writes and stored reads allow 1024 bytes, to fit LiveRamp envelopes (about 700 bytes). Reads skip UIDs over the limit instead of failing the whole row.
  • Withdrawal is the one exception to the single write. On any finalized request carrying the EC cookie, a consent withdrawal writes the KV tombstone (with a bounded CAS retry, as on main) and expires the cookie once every tombstone is in place.
  • Full rows. Batch sync reports a write that would exceed the row size cap as row_full and continues.
  • No prune-only writes. A plan whose only change is pruning expired data makes no write.
  • Row size cap is enforced with incremental size tracking.

Behaviour changes from main

  • EC minting needs Fetch Metadata. A new EC is issued only on navigations with Sec-Fetch-Dest: document and Sec-Fetch-Mode: navigate, the same check that permits its row write. Clients that send no Fetch Metadata no longer get a new EC.
  • Browser EIDs are written only on navigations. EIDs in an /auction or page-bids request body go to bidders but are never persisted.
  • A newly minted EC is withheld. It is not sent as user.id and no provider lookup runs for it until a later navigation finds its row.
  • Providers run on Fastly only. An unknown or unsupported provider kind fails startup, and the Axum, Cloudflare and Spin adapters refuse to start with an enabled provider.
  • Rollback risk. Builds older than this PR reject a whole KV row that holds a UID over 512 bytes, so rolling back after LiveRamp envelopes are stored makes those rows unreadable. A config blob that sets the new identity settings cannot be read by an older build either; with the defaults they are omitted.

Cleanup

  • Removed EID resolution, create-if-absent and proxy inputs left unused after the page-load write, along with the snapshot preload for a freshly generated EC.
  • The partner registry is built once per app state and reused by both phases; the core clock is used throughout.
  • ProviderResponse debug output hides the body and any request context.

Closes

Part of #1246 and #1245; the provider implementations that close them come in follow-up PRs.

Spec

Spec: docs/superpowers/specs/2026-10-30-s2s-eid-collection-design.md, included in this PR.

Test plan

  • cargo test-fastly && cargo test-fastly-reuse && cargo test-axum && cargo test-cloudflare && cargo test-spin
  • All eight clippy aliases (clippy-fastly, -axum, -cloudflare, -cloudflare-wasm, -spin-native, -spin-wasm, -cli, -codegen)
  • cargo fmt --all -- --check
  • Parity tests, native template build-digest test and clippy, scripts/lint-wasm-clock.sh
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run (plus npm run format and npm run lint)
  • Docs format and Markdown prettier check
  • ./scripts/test-cli.sh, domain lint against origin/main, CI Node pin guard, docs-proposal scripts
  • HTML EOF checks (native harness, Axum and Fastly auction controls)
  • Adapter-level tests for the after-response path (needs a registered provider kind; covered end to end in Add identity providers for ID5, LiveIntent, LiveRamp, ConnectID and UID2 #1488)
  • Manual testing via fastly compute serve

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 (not println!)
  • New code has tests
  • No secrets or credentials committed

Closes #1567

Add EID value types, an additive KV schema for provider-written EIDs with owner and expiry, EID-level fields on the OpenRTB Eid, and a resolver that serves only live UIDs to auctions, x-ts-eids, identify and the admin auction preview. Auction EIDs merge per (source, inserter, matcher, mm, ext) with first-occurrence UID dedupe. KV writes over 16 KiB are refused.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Top-level document navigations on the publisher route receive a single-use write permit; finalize plans one row change (browser ingest, provider results, prune, size cap) and commits it once without retry. Non-navigation requests and redirects never write, integration proxies no longer mint ECs, and new ECs are created by the page-load write instead of at generation. Withdrawal tombstones only the active EC and keeps the EC cookie when the tombstone does not land; the withdrawal completion marker is the one deliberate extra write.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Add the IdentityProvider/ProviderKind interface and [[ec.identity_providers]] config, response validation, a deadline-bounded LookupTransport over select_until, the IdentityCoordinator with consent, allowlist and budget checks, the trusted-server-identity-providers crate (no kinds yet), and Fastly wiring that dispatches before the origin fetch and collects before the single page-load write.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Orphan recovery minted a new EC ID and then discarded the navigation's provider results because they were looked up under the orphaned ID. Lookups depend on the browser, not the EC ID, and the orphan has no row, so retarget them to the replacement as the spec requires.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Collection that starts after its deadline takes one nonblocking snapshot. A request still in the headers state moved to the body state without reading bytes, which counted as no progress, so a response that had fully arrived was abandoned unread and reported as a timeout. Count entering the body state as progress.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
A response body the host already holds can report not ready until the host services async I/O once, so an expired snapshot abandoned identity responses that arrived well before the deadline and reported them as timeouts. Give each pending body one host select capped at 1 ms before abandoning it.

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

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
@dhruv8sh dhruv8sh self-assigned this Nov 2, 2026
Page-load writes only run on real navigations, which browsers mark with both sec-fetch-dest: document and sec-fetch-mode: navigate. Without the mode header the consent withdrawal scenario never got a write permit, so the EC cookie was kept.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
A lookup sends the EC ID to the provider before finalize can decline the EC, so GPC, sale opt-out and unknown-jurisdiction requests must not dispatch. Matches the gate pull sync already applies.

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

LiveRamp envelopes are about 700 bytes, so the 512-byte limit dropped every one from KV and the auction. Rows written under a higher limit now load with the oversized IDs removed instead of failing as a whole.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
@dhruv8sh
dhruv8sh force-pushed the feat/s2s-eid-collection-core branch from 304f395 to 2de6d0d Compare November 3, 2026 14:29
@jwrosewell

Copy link
Copy Markdown
Collaborator

@dhruv8sh, directionally the layout here is not compatible with V2 (the 55 pull requests tracked in #777, merged in order).

This pull request creates one shared trusted-server-identity-providers crate, and #1488 adds eight vendors to it as files. In V2 each identity vendor is a module in a folder of its own at identity/<vendor>, where Lockr already is, with its own named maintainer. A module hands core the identifiers it learned for a request, and the Edge Cookie itself is a module.

The V2 spec is identifier-modules and pluggable-providers, with the folder layout in integrations-crate, all in #1342. The associated pull requests are #1382 (a module hands core its identifiers, starting with the Prebid EID cookie) and #1343 (the Edge Cookie as a module, with the HMAC module built in).

Could you evaluate the V2 spec and those pull requests before progressing this one?

…readable rows

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

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Seeds a live row for the other EC so the EC-changed guard is the only thing that stops the after-response send, fixes doc comments that predate startup-built coordinators and the default Ec budgets, and moves tests out from between use lines.

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

The vendor gate covers browser IDs and disabled providers and is lost when a provider entry is removed; an orphan-recovered row gets no after-response lookups; ts-ec is expired only once the tombstone lands while the pull-sync marker is expired on every withdrawal.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Aligns the status line, redirect rule, write table, UID and ext limits, coordinator trait, WritePlanInput sketch, rate-limit wording, disabled-provider gate, classify visibility, outcome logs, compatibility list, PR split and scenario 1 with the code, and links the deferrals to their issues.

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

dhruv8sh commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Round-3 review-body items:

Round 4 found nothing blocking. State in section 5.2.1 that the after-response phase skips its read when no result can change the row.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
A disabled provider's TCF vendor ID was dropped from the egress gate when an enabled provider claimed the same source, so the disabled provider's stored UIDs were served with only the new owner's vendor consent, or with none. The partner now keeps every configured provider's vendor ID and withholds the source unless each has consent.

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
@dhruv8sh
dhruv8sh removed this pull request from stack #1515 November 12, 2026 13:08
@dhruv8sh
dhruv8sh added this pull request to stack #1563 November 12, 2026 13:08
…sponse

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

# Conflicts:
#	crates/trusted-server-core/src/config.rs
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

Round 3 from me, at 1600781. My round-2 blockers are fixed, and so is the over-cap blocker from aram356's round 3. This pass covered every commit since 4e7a6e0, the merge with main, and the production changes between 889159c and 4e7a6e0. No blocking findings.

Round-2 findings

Finding Status
Config push with an enabled provider takes the service down Fixed: validate, push and diff reject any enabled provider
Vendors sent EC IDs that have no row Fixed
Disabling or removing a provider drops its vendor gate Disabled: fixed, now also on a source another provider claims. Removed: tracked in #1548, and the docs say to keep the entry with enabled = false
Spec has no review log Fixed
Client copies bypass vendor consent; cookie UIDs of 513 to 1024 bytes Fixed, tested at both auction call sites
Tombstone retry keeps the cookie; re-read when nothing changes Documented; fixed
64 KiB response limit applied after buffering Tracked in #1550

Checked in this pass

  • Over-cap rows (4f946fc): traced each branch. An ID added to a row written on main lands. This navigation's browser UIDs make way for provider UIDs. A refresh that no longer fits keeps the stored record, and trimming alone never writes.
  • Reassigned source (92ae178): a source now goes out only while every configured provider's vendor has consent. This holds back a new owner's fresh UIDs while the disabled provider's vendor lacks consent. Each KvUid already records its owner, so #1548's owner-based egress would make this exact.
  • Remembered opt-out (e6b22d2): remember_opt_out keeps the original end time, so writes don't extend the 30 days.
  • Merge with main: the config.rs conflict was imports only. Provider config already uses deny_unknown_fields, so a misspelled tcf_vendor_id fails at load and can't silently drop the gate.
  • 1600781: test-only. It builds the jar cookie with Secure, and the test checks the same outcome.

Non-blocking

⛏ nitpick

  • Say that a shared source needs every vendor's consent. docs/guide/configuration.md (around lines 915-924) describes one provider's gate. Since 92ae178, a source claimed by several configured providers is withheld unless all their vendors consent. An operator moving a source from vendor A to vendor B would see B's IDs held back for visitors who consented to B only. One sentence after the /_ts/api/v1/identify sentence would cover it, for example: "When several configured providers claim a source, its IDs are sent only while every one of their vendors has consent."

CI Status

  • Local at 1600781: cargo fmt --all -- --check clean; core publisher:: and auction-endpoint tests 466 passed, 0 failed; core clippy with tests and -D warnings clean. At 22f7045, the core EC, settings and config tests passed (1488).
  • Hosted at 1600781, when this review was submitted: 14 pass, CodeQL neutral, and 11 still running or queued (including cargo test, the parity suite and vitest). This approval does not cover their results.
  • At 22f7045, cargo test (ts CLI, native) (macos-latest) failed in browser_collector::tests::exhausted_page_settle_budget_still_polls_gpt_registry. This PR doesn't touch the CLI and the Ubuntu run passed, so it looks unrelated.

aram356 added a commit that referenced this pull request Nov 12, 2026
Reverse the provider stack to #1487, #1488, #1489 and refresh the moved
heads. Split the EdgeZero release so Axum serving does not wait for the
record contract (R1b), add the ClientInfo contract (EZI-3) and JA4
(IABTechLab/edgezero#283) that ID-7 needs on Axum, and move the Axum side
of providers, abuse protection and SharedID write-back into A10 so Fastly
no longer waits on the Axum chain. Drop blockers that code at the PR heads
shows are already met (#1189, #1556, #1141 before A1), move four-adapter
and decision-gated issues out of the MVP, and align priorities and sizes.
The MVP is now waves 0 to 9.
@aram356

aram356 commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator

CREATOR: From the server-side identity plan (#1559, c7dc9de77): Every item from 4e7a6e02b is still open. Rebase onto #1526 and #1506 (8 and 17 files conflict). Await lookup collection in the async finalize: main.rs:699 calls block_on inside a function #1526 wraps in block_on (main.rs:550, :577), and run_after_response_lookups does the same at :793 and :811. Move per-request lookups out of the thread_local (app.rs:414-424). Keep saving /auction body EIDs (ID-8): the test at finalize.rs:1365 asserts they are dropped and the PR's own spec now calls that intended, so the comment argues against that spec line. Send lookups through EdgeZero's HttpClient: #1506 deletes platform/http.rs, and its outbound_client().start_batch_until has the same start-then-gather shape.

Blocks #1591, #1246, #1549, #1553

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.

Collect identity provider EIDs server-side for the server-side auction

7 participants