Skip to content

sync_audiences.uids: underspecified value shape per uid_type causes silent match failures #6872

Open

Description

@ohalushchak-exadel

Summary

The audience-member schema used by sync_audiences has:

"uids": {
  "type": "array",
  "description": "Universal ID values (MAIDs, RampID, UID2, etc.) for user matching.",
  "items": {
    "type": "object",
    "properties": {
      "type": { \"\$ref\": \"/schemas/enums/uid-type.json\" },
      "value": { \"type\": \"string\", \"description\": \"Universal ID value\" }
    }
  }
}

type is one of 11 enumerated values (rampid, rampid_derived, id5, uid2, euid, pairid, maid, hashed_email, publisher_first_party, world_id_nullifier, other). But value is just "Universal ID value" — no per-type shape, encoding, normalization, or case-sensitivity guidance.

Contrast with the sibling hashed_email and hashed_phone fields on the same object: both get explicit normative guidance ("SHA-256 hash of lowercase, trimmed email address", "SHA-256 hash of E.164-formatted phone number"), plus a pattern ^[a-f0-9]{64}\$. UIDs get nothing.

Why it matters

Buyer sends {"type": "uid2", "value": "..."} on sync_audiences. Receiver's identity-match path decodes an inbound UID2 token to a raw 32-byte identifier and keys audience memberships on a canonical form of those bytes. If the buyer's value isn't the same shape the receiver hashes on, the audience is silently invisible.

Concrete example: UID2 has TWO on-the-wire forms — the advertising token (~200-char encrypted, rotates per mint) and the raw UID2 (44-char standard base64 of 32-byte SHA-256, case-sensitive). Only the raw form makes sense for audience membership; the token is per-request rotating. But the spec doesn't say which one belongs in value, so different implementations will disagree.

Same silent-failure pattern for every other uid_type — each has an unstated wire shape that receivers key on. When buyer and receiver disagree, matches drop to zero without any error signal.

Proposed fix

Extend either uid-type.json enumDescriptions or the audience-member.uids[].value description with per-type shape guidance. Preferred: enumDescriptions, since the same enum is used in other contexts (event logging, TMP identity match) and the shape guidance is universal.

Draft shape guidance per type (community review welcome — please push back on any that are wrong):

`type` Expected `value` shape Case-sensitivity Notes
`rampid` LiveRamp maintained envelope, 32 bytes on the wire (per uid-type.json enumDescription) Case-preserving Envelope only LiveRamp can resolve; the value here is opaque to the receiver. See below for the mapped-form ambiguity.
`rampid_derived` LiveRamp derived envelope, 48 bytes on the wire Case-preserving Same envelope-vs-mapped ambiguity as `rampid`.
`id5` ID5 universal ID string, as issued by ID5 (buyer-decodable) Case-preserving Opaque bearer string.
`uid2` Raw UID2: 44-char standard base64 (`+`/`/`, `=` padding) of a 32-byte SHA-256. NOT the encrypted UID2 advertising token. Case-sensitive (per UID2 spec) The encrypted advertising token rotates per mint and cannot be used for audience keying.
`euid` Raw EUID: same shape as raw UID2, EU jurisdiction Case-sensitive (per EUID spec)
`pairid` PAIR wire token, base64-encoded encrypted KsKp Publisher Identifier Case-sensitive Publisher-scoped — see #6684 / #6698. `audience-member` currently has nowhere to carry the `EID.matcher`; needs a separate design pass.
`maid` 32-hex-char or 36-hex-char (dashed UUID) advertising identifier Case-insensitive Both dashed and undashed accepted; receiver normalizes.
`hashed_email` 64-char lowercase hex SHA-256 of lowercase-trimmed email Case: hex is unambiguous Overlaps with the top-level `hashed_email` field — clarify precedence when both are present, or forbid appearing in both.
`publisher_first_party` Publisher-defined string, opaque to buyer Publisher-defined The receiver's `uid.value` handling depends on which publisher context this is being asserted under; add guidance.
`world_id_nullifier` Nullifier bytes TBD — hex or base64? Please specify. The uid-type.json description notes trust requires an accompanying attestation, but the wire encoding of the nullifier itself isn't stated.
`other` Buyer-defined string, buyer-decodable N/A Fine as-is; documenting that no shape is guaranteed is enough.

Two open ambiguities beyond wire shape

Even with per-type shape guidance, two structural questions remain that a doc-only fix can't resolve:

  1. RampID envelope vs mapped form. LiveRamp's usable-for-matching form is not the wire envelope — it's a mapping the receiver has to compute via a LiveRamp sidecar call. If the buyer uploads a raw envelope in `value`, the receiver's audience store won't match unless the receiver ALSO calls the sidecar at ingest, OR the buyer uploads a pre-mapped form. Neither is documented today. Options:
    • (a) Spec mandates `value` = raw envelope; receiver runs sidecar at ingest.
    • (b) Spec says `sync_audiences` doesn't support LiveRamp; use a separate batch import protocol.
    • (c) Spec adds a new field like `value_form: "envelope" | "mapped"` so receiver knows what to do.
  2. PAIR matcher scope. `audience-member` has nowhere to carry the `ownerdomain` matcher scope PAIR requires per #6698. Any `pairid` upload today is publisher-ambiguous. Needs an additive schema field or explicit "not supported yet" note.

Both are worth their own dedicated design conversations; happy to open separate follow-up issues if the community agrees they need spec-level resolution rather than implementation-side workarounds.

Implementation note

This is not just a doc improvement — it's a source of real silent-failure bugs. Multiple implementations of `sync_audiences` today (buyer platforms, adapters, downstream identity stores) pass `uid.value` through opaquely because the spec gives them no shape to validate against. Any hardening on the receiver side without matching hardening on the buyer side just changes the failure mode from "silently wrong hash" to "silently rejected input." Getting the shape written down lets both sides converge.

Willing to draft a PR against uid-type.json enumDescriptions if there's alignment on the shapes above. Please push back on anything wrong before I do — I'd rather not codify inference-from-implementation as normative spec text.

Activity

  1. added
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Aug 25, 2026
  2. bokelley commented on Aug 25, 2026

    @bokelley
    Contributor

    Triage

    Classification: Spec question — underspecified uid wire shapes causing silent interop failures
    Bucket(s): spec / protocol, signals, schema
    Status: ready-for-human (parts 2–4 need WG decisions); parts 1 and 5 ready to implement independently
    Milestone: Spec Backlog

    What the experts said:

    • ad-tech-protocol-expert: Framing correct — the validator gap vs hashed_email/hashed_phone is real; uid2/euid raw-vs-token has clear external authority (UID2 Framework v3: advertising token MUST NOT appear in audience keying); maid and world_id_nullifier similarly clear; RampID encoding is blocked on LiveRamp input; pairid without ownerdomain is semantically unresolvable per PAIR spec §4.2; hashed_email overlap allows pattern bypass and should be structurally forbidden (minor-breaking).
    • adtech-product-expert: Silent 0-match is the worst ad-tech failure mode (blamed on data, not protocol); uid2/euid/maid wire shape fixes should ship without WG vote; RampID involves commercial LiveRamp relationships AdCP shouldn't mandate unilaterally; pairid in audience-member is premature without a real implementation; hashed_email overlap is a correctness bug resolvable with prose cross-validation note (non-breaking).

    My take: The issue correctly identifies a real interop gap. Parts 1 and 5 have unambiguous external authority and can ship as patch-level enumDescription additions. Parts 2–4 are genuine WG decisions where the experts diverge or require external input before a path can be codified.


    Parts 1 + 5 — ready to implement

    Additive prose to uid-type.json enumDescriptions only. Non-breaking (description-only). Patch-eligible on both main and 3.1.x.

    • uid2: "Raw UID2 only: 44-char URL-safe Base64 of a 32-byte SHA-256. Advertising token form (opaque, ~200 chars, rotates per mint) MUST NOT appear here — it is ephemeral, DSP-specific, and cannot be used for audience keying."
    • euid: Same contract as uid2, EU jurisdiction.
    • maid: "Lowercase dashed UUID: ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$. IDFA MUST be downcased before storage. Undashed hex form not accepted."
    • world_id_nullifier: "Base64url-encoded 32-byte nullifier hash (Semaphore protocol output, no padding). Wire encoding is distinct from the hex form used in World ID client SDKs."
    • Likely files: static/schemas/source/enums/uid-type.json (enumDescriptions only)
    • Non-breaking rationale: description strings only; adds no new required fields, no pattern constraints on the schema object, no enum changes

    Part 2 — WG decision: RampID envelope vs. mapped form

    Both experts agree this requires LiveRamp input before landing.

    Option A — mandate raw envelope; receiver calls LiveRamp sidecar at ingest:

    { "type": "rampid", "value": "<raw LiveRamp XY envelope>" }

    Requires every receiver to have an active LiveRamp API relationship. AdCP would be implicitly mandating a commercial contract.

    Option B — document rampid/rampid_derived as not currently supported in sync_audiences:

    - "rampid": "LiveRamp RampID maintained form (XY, 32 bytes on the wire)"
    + "rampid": "LiveRamp RampID maintained form (XY, 32 bytes on the wire). Not currently supported in sync_audiences audience-member without a LiveRamp sidecar — see issue #6872."

    Honest about current state. Zero schema change.

    Option C — add optional value_form field to uids[] item:

    { "type": "rampid", "value": "<envelope>", "value_form": "envelope" }

    Most informative. Requires knowing the wire encoding first. Additive optional field — non-breaking once encoding is confirmed.

    WG should invite LiveRamp to comment on encoding and sidecar expectations before choosing.


    Part 3 — WG decision: pairid ownerdomain in audience-member

    Option A — add optional ownerdomain field to uids[] item:

    {
      "type": "pairid",
      "value": "<URL-safe Base64-encoded KsKp Publisher Identifier>",
      "ownerdomain": "publisher.example.com"
    }

    Non-breaking (additionalProperties: true already permits it; formal definition is additive). Required when type=pairid (if/then) would be minor. Protocol expert favors this.

    Option B — document pairid as not supported in audience-member until ownerdomain is added:

    + "pairid": "...Not currently supported in sync_audiences audience-member without
    +  an out-of-band ownerdomain binding. Use is not recommended until schema support lands."

    Zero schema change. Product expert favors this given early adoption stage.


    Part 4 — WG decision: hashed_email in uids[] vs. top-level field

    Experts disagree on the fix approach.

    Option A — forbid hashed_email and hashed_phone as uids[].type (protocol expert):

    + "not": { "properties": { "type": { "enum": ["hashed_email", "hashed_phone"] } } }

    Closes the validator gap. Minor-breaking (invalidates docs using uids[{type:"hashed_email"}]) but real-world breakage is near-zero — any such sender is already failing at match time.

    Option B — prose cross-validation only (product expert):

    // uids[].value description addition:
    // "If type is hashed_email or hashed_phone, value MUST match ^[a-f0-9]{64}$.
    //  If both uids[] and the top-level field are present they MUST agree;
    //  receivers SHOULD prefer the top-level field."

    Zero schema change; doesn't close the validator gap but clarifies spec intent.

    @bokelley, your call on A vs B — the breaking vs. non-breaking tradeoff is the deciding factor here.


    Triaged by Claude Code. Session: https://claude.ai/code/${CLAUDE_CODE_REMOTE_SESSION_ID}


    Generated by Claude Code

  3. added
    claude-triagedIssue has been triaged by the Claude Code triage routine. Remove to re-triage.
    schemaJSON Schema source-of-truth: definitions, codegen artifacts, validation, hygiene
    needs-wg-reviewBlocked on a working-group decision — surface in WG meeting agendas
    signalsIssue concerns the signals protocol domain
    and removed
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Aug 25, 2026
  4. added this to the Spec Backlog milestone on Sep 21, 2026
  5. modified the milestones: Spec Backlog, 3.3.0 on Oct 8, 2026
  6. removed
    needs-wg-reviewBlocked on a working-group decision — surface in WG meeting agendas
    on Oct 28, 2026
  7. bokelley commented on Oct 31, 2026

    @bokelley
    Contributor

    Part of this shipped: #8059 (3.3.0-beta.0) specified the uids[].value wire form for uid2, euid, maid and hashed_email (parts 1 and 5 of the triage). Its description says the issue stays open for the rest.

    The rest was not decided for 3.3, so per the WG rule ('anything not decided moves to 3.4') only the remainder moves to 3.4: RampID envelope versus mapped form (needs LiveRamp input), PAIR ownerdomain, the world_id_nullifier encoding, and forbidding hashed_email / hashed_phone as a uids[].type. Objections by Fri 2026-11-06. The milestone stays 3.3.0 for now; it will be moved after Nov 6.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    claude-triagedIssue has been triaged by the Claude Code triage routine. Remove to re-triage.schemaJSON Schema source-of-truth: definitions, codegen artifacts, validation, hygienesignalsIssue concerns the signals protocol domain

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions