Skip to content

Implement server-side identity foundation and Lockr capture - #1260

Draft
jevansnyc wants to merge 6 commits into
mainfrom
spec/lockr-identity-capture
Draft

jevansnyc wants to merge 6 commits into
mainfrom
spec/lockr-identity-capture

Conversation

@jevansnyc

@jevansnyc jevansnyc commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Implements the server-side identity foundation and ID5-only Lockr proxy-response capture, with the reviewed specs and implementation plan.

  • Keep schema-v1 KV records under the full EC ID; add optional expiry/writer/revision and expiry-aware auction/identify reads.
  • Validate explicit source ownership across deploy/config-push/runtime/startup. Owned sources retain read-only identify but reject batch writes and legacy pull.
  • Persist registered /auction body EIDs conservatively during existing finalization. Retire ts-eids ingestion/writer and EID diagnostic emission; preserve inbound strips and authenticated local admin retirement on all adapters.
  • Inspect selected proxy responses within independent 64 KiB wire/decoded limits, preserving original response parts/bytes/stream errors. Stage sealed, consent-restrictive effects and apply after Fastly send, before legacy pull.
  • Add Lockr ID5 parsing, expiry/association validation, redacted logging and independently gated global revoke handling. Preserve ts-ec; no replacement EID cookie.

Verification

  • cargo test-fastly: 219 adapter + 2,924 core tests passed (six core ignored), shared/doc tests passed.
  • cargo test-fastly-reuse, all three native adapter suites, ./scripts/test-cli.sh including required ignored browser fixtures, and native build-digest tests passed.
  • Full JS suite: 1,186 tests across 48 files, no type errors; all 13 JS modules build.
  • Target-matched Clippy passed for all adapters, CLI/codegen and native build-digest tests. Fastly/Axum/Cloudflare development builds passed.
  • Rust formatting, scoped JS/docs formatting and whitespace checks passed. Recording executor tests prove send-before-storage, no-effects/unavailable-storage behavior, and persisted context snapshot handoff.

Evidence and reproducible commands: docs/superpowers/plans/2026-10-09-server-side-identity-foundation-and-lockr.md.

Activation gates / deliberately deferred

This is a draft implementation, not permission to activate capture. The rollout example explicitly sets capture_identity = false and capture_global_withdrawal = false. Capture defaults to true for compatible configuration; operators must explicitly disable it until the gates pass.

Not verified: provider-approved sanitized fixtures/mappings, consent/revoke scope and application-success semantics, overlap ordering, live acceptance, metadata-compatible deployment propagation and old-invocation drain, external diagnostic consumers, and remote CI. Old writers can erase lifecycle metadata and old readers ignore expiry; do not activate managed writes during mixed-binary deployment.

Native LiveRamp resolution, identifier intake, refresh/invalidation scheduling, durable retries and cross-browser linkage remain deferred. Other adapters do not claim Fastly-style post-send KV persistence.

Refs #1246, #1245.

@ChristianPavilonis
ChristianPavilonis marked this pull request as draft October 9, 2026 21:23
@ChristianPavilonis ChristianPavilonis changed the title Add proxy-side identity capture design spec for lockr-issued IDs Implement server-side identity foundation and Lockr capture Oct 9, 2026
Comment thread crates/trusted-server-core/src/publisher.rs Dismissed
@ChristianPavilonis

ChristianPavilonis commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Clarifying the two cookie decisions, and recording the implemented follow-up.

Existing ts-eids

For our normal /auction callers, ts-eids was largely redundant with EIDs already submitted in the request body. The old reader used body-or-cookie fallback, not missing-source merge: a valid body caused it to ignore the cookie even when the body lacked a source.

Restoring it may or may not provide much benefit. It could ingest IDs discovered after request construction on a later navigation without another auction submission. That benefit remains a separate decision. This change leaves the browser-written cookie retired.

Why the capture cookie was initially removed

I originally removed the same-response capture cookie because it could not guarantee first-auction availability. The browser can send /auction before the Lockr response installs ts-eids-s, so adding the cookie does not solve that first-request race.

That timing limitation remains, but it does not eliminate the cookie's value for later auctions or recovery after a failed post-send write. The restored cookie is a bounded, authenticated handoff for those cases, not an unconditional first-auction guarantee.

Authenticated ts-eids-s

The capture cookie carries validated Lockr observations that the normal browser collector does not submit. The follow-up is implemented in 16b6d09a as a transient handoff, not the original unsigned, cookie-first, 30-day design.

  • Default-off [ec.identity_handoff] opt-in, dedicated publisher signing keys through existing app-config secret handling, rotating key IDs, domain-separated HMAC-SHA256, publisher/full-EC binding. The EC passphrase cannot be reused.
  • Maximum five-minute entry lifetime and 2,048-byte complete signed value, including envelope/signature overhead. Provider expiry can shorten it. Reads, replay, NoChange and matching captures do not renew the original deadline. Missing expiry for the same pending UID retains known expiry, without changing any stored record.
  • At current PR head, an installed valid cookie can supply an absent source to a later auction from an observed live, consenting root before the capture write becomes visible. Existing records still block that fallback. The local expiry follow-up below relaxes this only for known-expired IDs.
  • A qualifying later request, including a permitted Lockr response with no new tokens, can stage one fill-absent-only retry. Fastly sends first, then proves the root and applies bounded CAS before legacy pull work. No root creation/recovery, stale replacement, provider refetch or durable queue. A later response acknowledges persistence and clears the cookie.
  • Cookie-bearing responses are private/no-store and preserve upstream body bytes and encoding. PBS Cookie forwarding strips the handoff in every consent-forwarding mode. Unsupported adapters keep issuance/replay inactive.

Expired-source fallback follow-up

Implemented and verified locally, not yet committed or pushed to this PR:

  • Usable stored IDs remain preferred. When the stored ID has expired, a valid authenticated capture cookie can supply the auction ID, including the same UID with a known refreshed expiry or a different captured UID.
  • Missing provider expiry cannot revive a matching known-expired UID. Empty records, denied consent, missing/unreadable roots and observed withdrawals still block fallback.
  • Capture can issue a handoff over an expired record. Reconciliation retains it only until its original signed deadline, without renewing that deadline or changing stored UID, expiry, writer or revision.
  • Persistence replay remains fill-absent-only. An expired stored source is not an empty slot; its persistent refresh still requires direct capture.

This uses expiry and current usability, without adding cookie revisions or an ordering mechanism for hypothetical overlapping different-ID responses. It still cannot supply an auction that left before cookie installation.

Read consistency also remains unchanged: eligibility uses the request-local KV snapshot. A stale live read can miss newer state or a withdrawal elsewhere and still deliver the pending ID. Current request consent always applies; replay CAS cannot overwrite that newer record or revive its tombstone. There is no strong-read or instant cross-POP withdrawal guarantee.

Verification and rollout

Latest local worktree verification passes: 44 identity/handoff/codec tests, 2,995 native core tests, 219 Fastly adapter tests and 2,989 Fastly core tests, with six native-only core tests ignored under WASM. Reusable Fastly, other adapters, CLI browser fixtures, parity, native build-digest checks, all target-matched Clippy aliases, JS tests/build/format and Markdown/Rust formatting also pass. The four new regressions failed before their respective fixes and now pass. Specs, operator guidance and the implementation plan record the revised behavior and evidence. These local results include the unpushed expiry follow-up; remote CI has not tested it.

Live provider acceptance, operational key provisioning/rotation, provider-approved fixtures/consent/revoke scope, deployment drain and actual cross-POP timing remain unverified. Keep handoff off and Lockr capture explicitly disabled until those rollout gates pass.

@ChristianPavilonis ChristianPavilonis linked an issue Oct 12, 2026 that may be closed by this pull request

@jevansnyc jevansnyc left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Review of the implementation commits (through 1eaf3c5)

Thanks for carrying this through to working code. The foundation pieces are things I want to keep: expiry-aware records, source ownership, the bounded response inspection that preserves the original bytes, and send-before-write on Fastly. The two follow-up commits also already close three of the things I had flagged:

  • ids.id5id.eid nesting in id5_token, plus the test now asserting Issued instead of len() == 1.
  • Legacy USP (section 6) inside a GPP string now decodes, with the malformed-section cases covered.
  • Writer-keyed provenance reconstruction for Lockr-written ID5 records. Keying on the persisted writer rather than identity_owner is cleaner than the static table in my spec, and it answers the parity requirement.

What is left falls into two groups: decisions from the original spec I want to keep open rather than accept by default, and a handful of code points. Inline comments carry the file-level detail.

Spec decisions I want to reopen

Same-response cookie and next-auction availability (original requirements 2 and 3). The spec had the captured ID written to a server-owned cookie on the same response and to KV after send. The implementation drops the cookie half and makes "no additional EID cookie" an invariant. On the affected route the Lockr page-view call and the first /auction both fire right after the consent gate, a few hundred milliseconds apart, so KV-only delivery loses the ID for the whole first page view, and there is no delivery path when the row is NotRead, failed, or absent. I am open to not adding ts-eids-s if we have a measured answer for how often the first auction beats the post-send write on that route. If we cannot measure it, I would like the cookie back.

Retiring ts-eids. The spec said the JS collector does not change and ts-eids keeps covering User ID module providers. The commit removes the writer, the server readers, the /auction fallback, the admin diagnostic and the x-ts-eids headers. That is a decision for every publisher running User ID modules, not a Lockr decision, and it has side effects on its own: late IDs that arrive after the last auction on a page are never persisted, and Axum, Cloudflare and Spin go from forwarding consented client EIDs to forwarding none, since they have no KV graph. The operator docs (docs/guide/ec-setup-guide.md, docs/guide/edge-cookies.md) still describe both. Body-EID persistence at finalization is a good addition and can land additively with the cookie as fallback. If you want to retire the cookie, let's give it its own spec and PR with a migration plan.

Code points still open

  • restrictive_identity_signals_allow still denies a GPP string with no US, USP or TCF section, which is the DBAA shape CMPs emit when nothing applies. Inline at consent/mod.rs.
  • Post-send capture writes have no is_real_browser gate, unlike pull sync. Inline at main.rs.
  • EidSyncSource::Auction is now set in both core and the Fastly adapter. Inline at endpoints.rs.
  • Six change_context calls in lockr.rs became map_err(|_| Report::new(..)), dropping the source error. Inline.
  • Small: hard-coded 512 in id5_token, PartnerRegistry::from_config rebuilt per captured response with a silent skip on error, and handle_page_bids no longer rejects a non-UTF-8 Cookie header now that handle_request_cookies is gone from that route.

Rollout notes worth stating in the Lockr guide

  • The id5-sync.com partner entry needs identity_owner = "lockr" or stage_capture drops every outcome without a log line.
  • An owned source rejects legacy batch writes and cannot have pull sync enabled, so an existing deployment with ID5 on pull sync has to migrate first.
  • Rolling back to a UID-only binary after the first managed write strips expires_at, writer and revision. You call this out already; it needs a named procedure before activation on a production service.
  • With capture_identity defaulting to true, I would rather it default to false until the live acceptance steps have run once.

Requested changes

  1. Split into separate PRs so each can be reviewed and rolled back on its own: the foundation; the Lockr capture consumer; and a separate proposal for body-EID persistence and any ts-eids change.
  2. Keep #1260 as the spec PR. Your foundation spec and plan can go in their own docs PR, and my spec gets a short decisions section recording what we agree to change and why.
  3. Keep ts-eids, its readers, the admin diagnostic and the x-ts-eids headers in this round.
  4. Fix the DBAA case and the browser gate before anything is activated.
  5. Drop the CI image commit from this branch.

Questions

  1. What is your preferred answer for the first auction on a first visit without a cookie, and how would we measure how often it happens on the affected route?
  2. Which GPP strings did you test the capture gate against? I want to confirm the DBAA case rather than assume it.
  3. Why retire ts-eids now rather than after body persistence has run in production for a while?

if gpp.us_sale_opt_out == Some(true)
|| gpp.eu_tcf.as_ref().is_some_and(|tcf| !tcf_allowed(tcf))
|| (gpp.section_ids.contains(&GPP_SECTION_ID_TCF_EU_V2) && gpp.eu_tcf.is_none())
|| (gpp.us_sale_opt_out.is_none() && gpp.eu_tcf.is_none())

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This still returns false for a decodable GPP string that has no US section, no USP section and no TCF section. That is the header-only DBAA shape a CMP emits when no section applies to the visitor, and also any string carrying only a non-US, non-EU section. The existing EC consent path treats that string as permissive, so capture ends up stricter than ID creation for visitors who have signalled nothing restrictive.

Suggest: a GPP string with no applicable section passes this check; only a decoded opt-out, a failing TCF section, or a declared-but-undecodable section (the new line above) should deny.

}

/// Keeps capture effects through cookie finalization, then consumes them after send.
fn send_identity_response(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

run_edgezero_pull_sync_after_send refuses to run when !ec_state.is_real_browser (line 637), but this path applies the staged capture effects regardless. A script replaying a copied ts-ec cookie can POST to the Lockr proxy and, if upstream issues a token, enrich the row; with capture_global_withdrawal on, it can tombstone it. Can we apply the same gate here before send_then_apply_identity?

&& consent_allows_server_side_auction(&consent_context)
{
ec_context.stage_browser_eid_updates(collect_prebid_eid_updates_from_eids(eids, registry));
ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Auction);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The Fastly adapter already sets EidSyncSource::Auction before dispatch (app.rs, NamedRouteHandler::Auction), and the doc on set_eid_sync_source describes it as adapter-assigned after request filters pass. With both writers an adapter that chooses not to classify a request gets persistence re-enabled here. Could we keep this in one place, probably the adapter?


let backend_name = Self::backend_name_for_url(services, &self.config.api_endpoint)
.change_context(Self::error("Failed to determine backend for API proxy"))?;
.map_err(|_| Report::new(Self::error("Failed to determine backend for API proxy")))?;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Minor: this and the five sibling sites moved from change_context to map_err(|_| Report::new(..)), which drops the backend lookup, request build and HTTP client errors from the report. The redaction goal is met by taking the URL out of the message; the chain can stay. AGENTS.md asks for change_context for this.

.get("universal_uid")?
.as_str()?
.to_string();
if uid.is_empty() || uid.len() > 512 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Nit: crate::ec::kv_types::MAX_UID_LENGTH is the cap stage_capture and KvEntry::validate enforce. Using it here keeps the parser and the shared boundary in step if it changes.


Invariants:

1. No additional EID cookie. `ts-ec` remains the pointer to an existing EC row.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is the point I most want to talk through. The original spec had the captured ID delivered two ways: a server-owned HttpOnly cookie on the same response, for the auction(s) on the current page, and KV after send for later navigations. With this invariant the first auction on a first visit never sees the ID, and a visitor whose row is missing or unreadable gets nothing until a navigation creates one. One ID5 token is 184 bytes on the wire.

I am open to not adding ts-eids-s if we can measure how often the first auction beats the post-send write on the affected route. Without that measurement I would like the cookie back as designed.


Delayed availability is accepted: a later auction sees an ID only after the
deferred write completes and its KV read can see the record. The first auction
may miss it. There is no cookie fallback or durable capture retry queue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This sentence is where the original requirement 3 (next auction on the same page sees the ID, cookie as fallback) is dropped. See my comment on the foundation spec invariant; I would rather this stay a requirement than a stated non-goal, unless we have numbers showing the loss is rare on the affected route.

log.debug(`[tsjs-prebid] synced ${payload.length} EID sources to cookie`);
} catch (err) {
log.warn('[tsjs-prebid] failed to sync EIDs cookie', err);
function expireRetiredPrebidEidsCookie(): void {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I did not want ts-eids retired as part of this work. The spec listed the JS collector as out of scope and said ts-eids keeps covering User ID module providers. Retiring it affects every publisher running those modules, drops IDs that arrive after the last auction on a page, and on Axum, Cloudflare and Spin (no KV graph) means no client EIDs reach the auction at all.

Body-EID persistence at finalization is a good addition and can land with the cookie still in place. If you want to propose retiring the cookie, let's do that as its own spec and PR with a migration plan, and the x-ts-eids header question can go with it.

/// graph. Only the Fastly adapter has a KV identity store, so Axum, Cloudflare,
/// and Spin pass `None`; client cookie EIDs are still resolved and consent-gated
/// on every adapter, so no adapter forwards unconsented EIDs.
/// and Spin pass `None` and cannot resolve server-side EIDs. Retired EID cookies

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This doc comment is the regression stated plainly: before this change these adapters forwarded consented client cookie EIDs on page-bids auctions, and after it they forward none. That is another reason to keep the cookie path in this round.

# sdk_url = "https://identity.example.com/trusted-server.js"
# cache_ttl_seconds = 3600
# rewrite_sdk = true
# capture_identity = false # require provider approval and old-binary drain

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Given how many gates still have to pass before this is safe to run, could capture_identity default to false in code as well, not only in the example? A deploy that enables the integration and sets identity_owner would otherwise start capturing before the live acceptance steps have run.

jevansnyc and others added 6 commits October 12, 2026 12:16
Lockr-issued IDs reach client-side bidders through ortb2.user.ext.eids but
never the server-side auction, the ts-eids cookie, or the EC identity graph
(#1246). This spec captures the IDs in the first-party proxy response from
lockr's API, sets a server-owned HttpOnly cookie on that response, and defers
the KV upsert to the post-send slot so no KV write sits on the hot path.
Read the associated ID5 EID from ids.id5id.eid and assert the issued
identity rather than only counting capture outcomes.

Normalize legacy USP sections in shared GPP decoding while preserving
opt-out precedence and failing acquisition closed on malformed signals.
Add parser, mixed-signal and capture persistence regressions.
Use the persisted acquisition writer and source to restore Lockr's fixed
EID provenance without changing the KV schema or capture activation.
Keep other writers unattributed and suppress reconstructed provenance
when browser input adds mixed-origin UIDs for the same source.

Update the specs, guide and verification plan, with regressions for
writer eligibility, browser precedence and the KV-to-bidder path.
Restore bounded absent-source availability and post-send retry through a
default-off publisher/full-EC-bound ts-eids-s handoff. Use dedicated
rotating signing keys, fixed deadlines and fill-absent-only CAS.

Preserve pending expiry and writer metadata without granting cookies
authority over stored records. Keep browser-written ts-eids retired and
document the existing KV snapshot consistency and live rollout gates.
@ChristianPavilonis
ChristianPavilonis force-pushed the spec/lockr-identity-capture branch from cbc7b42 to 16b6d09 Compare October 12, 2026 17:20
@aram356 aram356 added this to the 202610 milestone Oct 12, 2026
aram356 added a commit that referenced this pull request Oct 12, 2026
Brings #1260 at 16b6d09 (draft).

Integration decisions:
- Fastly delivery uses #1260's send_identity_response, extended with
  the RC's SendContext and post-send steps: identity effects apply after
  send, pull sync runs only when capture storage is available, and
  access telemetry always reports the delivered response.
- require_identity_graph stays untimed; its doc now names identity
  capture as a second post-send caller.
- The /auction response keeps #1107's trace cache privacy and drops the
  x-ts-eids headers, as #1260 does.
- The integration proxy route reads #1260's integration id from the RC's
  three-field route tuple.
- #1260 removes server-side ts-eids cookie parsing. #1107's trace cookie
  health now checks the ts-eids format with a local validator that
  accepts the same shapes the removed parser did.
- ec_finalize_kv_lands_before_freeze drives its KV round trip with a
  SharedID cookie instead of the removed ts-eids ingestion.

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

5 participants