diff --git a/.changeset/clarify-pair-wire-identity.md b/.changeset/clarify-pair-wire-identity.md new file mode 100644 index 0000000000..b88a6d47d2 --- /dev/null +++ b/.changeset/clarify-pair-wire-identity.md @@ -0,0 +1,6 @@ +--- +"adcontextprotocol": patch +--- + +Correct the OpenRTB source for PAIR identifiers and clarify that PAIR wire +values are rotating, publisher-scoped identifiers rather than universal IDs. diff --git a/docs/reference/glossary.mdx b/docs/reference/glossary.mdx index 09b6646975..2547e96bc1 100644 --- a/docs/reference/glossary.mdx +++ b/docs/reference/glossary.mdx @@ -383,7 +383,7 @@ AdCP's real-time execution layer. Determines which pre-negotiated packages shoul ## U **UID (Universal ID)** -An already-resolved, privacy-preserving user token from an identity graph. Used for cross-platform user matching. Distinct from [Hashed Identifiers](#hashed-identifier): UIDs are pre-resolved tokens (not raw PII); hashed identifiers are buyer-normalized PII hashed before sending. AdCP supports: `rampid` (LiveRamp RampID), `id5` (ID5), `uid2` (Unified ID 2.0), `euid` (European Unified ID), `pairid` (IAB Tech Lab PAIR), and `maid` (Mobile Advertising ID — IDFA/GAID). UIDs are accepted in audience uploads (`sync_audiences` → `audience_member.uids`) and event attribution (`log_event` → `user_match.uids`). Supported types vary by seller — check `get_adcp_capabilities` → `audience_targeting.supported_uid_types`. +AdCP's schema category for privacy-preserving user tokens. Most are already-resolved identity-graph tokens used for cross-platform matching. `pairid` is the exception: it is a Base64-encoded, encrypted KsKp Publisher Identifier scoped to a publisher owner domain, and it is not comparable across publisher scopes. Distinct from [Hashed Identifiers](#hashed-identifier): UIDs are tokens (not raw PII); hashed identifiers are buyer-normalized PII hashed before sending. AdCP supports: `rampid` (LiveRamp RampID), `id5` (ID5), `uid2` (Unified ID 2.0), `euid` (European Unified ID), `pairid` (IAB Tech Lab PAIR), and `maid` (Mobile Advertising ID — IDFA/GAID). UIDs are accepted in audience uploads (`sync_audiences` → `audience_member.uids`) and event attribution (`log_event` → `user_match.uids`). Supported types vary by seller — check `get_adcp_capabilities` → `audience_targeting.supported_uid_types`. **Universal Commerce Protocol (UCP)** An open standard developed by Google with Shopify, Walmart, Target, and others for commerce in AI assistants. UCP defines primitives for checkout, payments, and fulfillment. Along with ACP (Agentic Commerce Protocol), UCP represents the commerce layer that complements AdCP's advertising layer. diff --git a/docs/trusted-match/migration-from-axe.mdx b/docs/trusted-match/migration-from-axe.mdx index 29cdcd9896..3fd2b326db 100644 --- a/docs/trusted-match/migration-from-axe.mdx +++ b/docs/trusted-match/migration-from-axe.mdx @@ -96,7 +96,7 @@ For buyers bridging from OpenRTB-shaped pipelines, the TMP Identity Match `ident | `id5` | `id5-sync.com` | `atype: 3` | | `uid2` | `uidapi.com` | `atype: 3` | | `euid` | `euid.eu` | `atype: 3` | -| `pairid` | `iabtechlab.com/pair` | `atype: 3` | +| `pairid` | `pair-protocol.com` | `atype: 3`. The value is a Base64-encoded, encrypted KsKp Publisher Identifier scoped by `EID.matcher` to the publisher owner domain. | | `maid` | `adid` (Android) / `idfa` (iOS) | Atypically carried on `Device.ifa` rather than `User.eids` in OpenRTB | | `hashed_email` | `liveintent.com` or buyer-specific | `atype: 3` | | `publisher_first_party` | publisher-defined `source` URL | Context-dependent; bridge implementations may omit `atype` or default to `atype: 3` only when the token represents a person-based identifier | @@ -104,4 +104,6 @@ For buyers bridging from OpenRTB-shaped pipelines, the TMP Identity Match `ident The TMP `user_token` field corresponds to `User.eids[].uids[].id`. OpenRTB's `User.eids[].uids[].atype` is derived from AdCP's higher-fidelity `uid_type`; it is not a separate AdCP field. Bridge code should compute `atype` from the table above and should not add an independent user-supplied `atype` value that could disagree with `uid_type`. +For PAIR, `EID.matcher` is the publisher's canonical owner domain (the ads.txt `ownerdomain`). A publisher uses the same KsKp identifier for a user across its advertiser matches, but identifiers are not comparable across publisher scopes. A DSP matches only against advertiser-authorized, publisher-specific PAIR lists produced by the PAIR DCR flow; bridges must preserve the matcher scope and must not key identifiers across it. Older AdCP guidance listed `iabtechlab.com/pair`. A bridge that must cover traffic produced from that guidance may accept it as an explicit legacy input alias, normalize it internally, and must emit `pair-protocol.com`. + AdCP carries up to 3 identities per Identity Match request (HPKE size budget — see [TMPX size budget](/docs/trusted-match/specification#size-budget)); OpenRTB has no such limit, so a buyer bridging from OpenRTB into TMP must apply a buyer-configured priority order to truncate (typically: deterministic graphs first — UID2, RampID — then probabilistic or publisher-scoped IDs). diff --git a/server/src/billing/lazy-reconcile.ts b/server/src/billing/lazy-reconcile.ts index a90d74fe54..a5cc77bde7 100644 --- a/server/src/billing/lazy-reconcile.ts +++ b/server/src/billing/lazy-reconcile.ts @@ -1,7 +1,8 @@ /** * Lazy reconciliation: when a paywall gate is about to deny a request from - * an org that has a `stripe_customer_id` but no `subscription_status`, pull - * fresh state from Stripe and self-heal the org row before the deny fires. + * an org that has a `stripe_customer_id` but stale or incomplete subscription + * state, pull fresh state from Stripe and self-heal the org row before the deny + * fires. * * Catches a real drift class observed in production: a Stripe customer can * be re-linked between orgs (admin audit, support fix-up) without the @@ -13,21 +14,28 @@ * never sees the drift. * * Deliberate scope: - * - Only writes the subscription_* columns on the org row. + * - Only writes billing-derived subscription_* columns and membership_tier. * - Does NOT write `agreement_signed_at`, `user_agreement_acceptances`, * or `org_activities` rows. The webhook handler (handle-subscription-created) * is the canonical place for those side effects, and it's keyed off * `pending_agreement_user_id` set at checkbox-click time. A user clicking * a paywall is action-signal but not a fresh consent event. - * - Idempotent: `WHERE subscription_status IS NULL` guards against - * overwriting a status set by a webhook that landed between read and write. + * - Uses a lossless `updated_at` token to avoid overwriting a webhook update + * that lands between the read and repair write. + * - Requires the Stripe customer's organization metadata to match exactly, + * so a stale customer link cannot transfer entitlement across orgs. * - Safe to call on every paywall hit; only does Stripe work when the org * actually looks drifted. */ import type { Pool } from 'pg'; import type Stripe from 'stripe'; import type { Logger } from 'pino'; -import { pickMembershipSub } from './membership-prices.js'; +import { + buildSubscriptionUpdate, + resolveMembershipTierForSubscriptionWrite, +} from '../db/organization-db.js'; +import { invalidateMembershipCache } from '../db/org-filters.js'; +import { pickMembershipSubWithProductFetch } from './membership-prices.js'; /** * Stripe statuses that grant entitlement at AAO. Mirrors the gate logic @@ -46,17 +54,21 @@ export type LazyReconcileSkipReason = | 'no_stripe_customer' | 'stripe_error' | 'customer_deleted' + | 'customer_org_mismatch' | 'no_membership_sub' | 'sub_not_entitled'; interface OrgRow { workos_organization_id: string; stripe_customer_id: string | null; + is_personal: boolean; subscription_status: string | null; subscription_canceled_at: Date | null; stripe_subscription_id: string | null; + membership_tier: string | null; subscription_price_lookup_key: string | null; subscription_amount: number | null; + updated_at_token: string; } /** @@ -75,7 +87,7 @@ interface OrgRow { function isFullySynced(org: OrgRow): boolean { if (!org.subscription_status || !ENTITLED_STATUSES.has(org.subscription_status)) return false; if (!org.stripe_subscription_id) return false; - if (org.subscription_price_lookup_key === null && (org.subscription_amount ?? 0) <= 0) return false; + if (!org.membership_tier && org.subscription_price_lookup_key === null && (org.subscription_amount ?? 0) <= 0) return false; return true; } @@ -88,8 +100,8 @@ export interface LazyReconcileDeps { /** * Attempt to heal an org row from Stripe state. * - * Returns `{ healed: true, ... }` only if the row went from "no live - * subscription" to a written entitlement. Returns `{ healed: false, reason }` + * Returns `{ healed: true, ... }` only if Stripe entitlement was written to a + * stale, missing, or partial org row. Returns `{ healed: false, reason }` * for every skip path so callers can log the reason without taking action. */ export async function attemptStripeReconciliation( @@ -99,8 +111,10 @@ export async function attemptStripeReconciliation( const { pool, stripe, logger } = deps; const orgResult = await pool.query( - `SELECT workos_organization_id, stripe_customer_id, subscription_status, subscription_canceled_at, - stripe_subscription_id, subscription_price_lookup_key, subscription_amount + `SELECT workos_organization_id, stripe_customer_id, is_personal, + subscription_status, subscription_canceled_at, stripe_subscription_id, + membership_tier, subscription_price_lookup_key, subscription_amount, + updated_at::text AS updated_at_token FROM organizations WHERE workos_organization_id = $1`, [orgId], @@ -133,47 +147,75 @@ export async function attemptStripeReconciliation( return { healed: false, reason: 'customer_deleted' }; } + const stampedOrgId = (customer as Stripe.Customer).metadata?.workos_organization_id; + if (stampedOrgId !== orgId) { + logger.warn( + { orgId, customerId: org.stripe_customer_id, stampedOrgId: stampedOrgId ?? null }, + 'lazy-reconcile: Stripe customer org metadata mismatch; refusing heal', + ); + return { healed: false, reason: 'customer_org_mismatch' }; + } + const subs = (customer as Stripe.Customer).subscriptions?.data ?? []; - const sub = pickMembershipSub(subs); - if (!sub) return { healed: false, reason: 'no_membership_sub' }; - if (!ENTITLED_STATUSES.has(sub.status)) return { healed: false, reason: 'sub_not_entitled' }; + const picked = await pickMembershipSubWithProductFetch( + subs, + (productId) => stripe.products.retrieve(productId), + ); + if (!picked) return { healed: false, reason: 'no_membership_sub' }; + if (!ENTITLED_STATUSES.has(picked.sub.status)) return { healed: false, reason: 'sub_not_entitled' }; - const price = sub.items.data[0]?.price; + const payload = buildSubscriptionUpdate( + picked.sub as Parameters[0], + org.is_personal, + picked.product?.metadata ?? null, + ); + const membershipTier = resolveMembershipTierForSubscriptionWrite( + payload, + org.membership_tier, + ); - // The WHERE clause only writes when the row is still in a partial-truth - // state (no entitled status, or status set but key product fields missing). - // If a webhook beat us to a fully-synced state between our read and write, - // the update is a no-op — the webhook is the source of truth for live - // transitions; lazy reconcile only fills gaps. + // Optimistically lock on updated_at. This permits repair of a fully populated + // but stale non-entitled row (for example DB=canceled while Stripe=active) + // without overwriting a newer webhook transition that lands after our read. const updated = await pool.query( `UPDATE organizations SET subscription_status = $1, stripe_subscription_id = $2, - subscription_amount = $3, - subscription_currency = $4, - subscription_interval = $5, - subscription_current_period_end = $6, + subscription_current_period_end = $3, + subscription_amount = COALESCE($4, subscription_amount), + subscription_currency = COALESCE($5, subscription_currency), + subscription_interval = COALESCE($6, subscription_interval), subscription_canceled_at = $7, - subscription_price_lookup_key = $8, + subscription_product_id = $8, + subscription_product_name = COALESCE($9, subscription_product_name), + subscription_price_id = $10, + subscription_price_lookup_key = $11, + membership_tier = $12, updated_at = NOW() - WHERE workos_organization_id = $9 + WHERE workos_organization_id = $13 + AND updated_at = $14::timestamptz AND ( subscription_status IS NULL - OR subscription_status = 'none' + OR subscription_status NOT IN ('active', 'trialing', 'past_due') OR stripe_subscription_id IS NULL - OR (subscription_price_lookup_key IS NULL AND COALESCE(subscription_amount, 0) <= 0) + OR (membership_tier IS NULL AND subscription_price_lookup_key IS NULL AND COALESCE(subscription_amount, 0) <= 0) ) RETURNING workos_organization_id`, [ - sub.status, - sub.id, - price?.unit_amount ?? null, - price?.currency ?? 'usd', - price?.recurring?.interval ?? null, - sub.current_period_end ? new Date(sub.current_period_end * 1000) : null, - sub.canceled_at ? new Date(sub.canceled_at * 1000) : null, - price?.lookup_key ?? null, + payload.subscription_status, + payload.stripe_subscription_id, + payload.subscription_current_period_end, + payload.subscription_amount, + payload.subscription_currency, + payload.subscription_interval, + payload.subscription_canceled_at, + payload.subscription_product_id, + payload.subscription_product_name, + payload.subscription_price_id, + payload.subscription_price_lookup_key, + membershipTier, orgId, + org.updated_at_token, ], ); @@ -181,7 +223,7 @@ export async function attemptStripeReconciliation( // A webhook arrived between our read and write. The webhook is more // authoritative; treat as already-entitled. logger.info( - { orgId, customerId: org.stripe_customer_id, subId: sub.id }, + { orgId, customerId: org.stripe_customer_id, subId: picked.sub.id }, 'lazy-reconcile: row was already updated by a concurrent webhook; deferring', ); return { healed: false, reason: 'already_entitled' }; @@ -191,16 +233,18 @@ export async function attemptStripeReconciliation( { orgId, customerId: org.stripe_customer_id, - subId: sub.id, - lookupKey: price?.lookup_key ?? null, - stripeStatus: sub.status, + subId: picked.sub.id, + lookupKey: payload.subscription_price_lookup_key, + stripeStatus: payload.subscription_status, }, - 'lazy-reconcile: healed missing subscription_status from Stripe', + 'lazy-reconcile: healed stale subscription state from Stripe', ); + invalidateMembershipCache(orgId); + return { healed: true, reason: 'healed_from_stripe', - subscriptionStatus: sub.status, + subscriptionStatus: payload.subscription_status, }; } diff --git a/server/tests/unit/billing/lazy-reconcile.test.ts b/server/tests/unit/billing/lazy-reconcile.test.ts index ae52685ecd..1892dad153 100644 --- a/server/tests/unit/billing/lazy-reconcile.test.ts +++ b/server/tests/unit/billing/lazy-reconcile.test.ts @@ -9,12 +9,14 @@ import { attemptStripeReconciliation } from '../../../src/billing/lazy-reconcile const mockQuery = vi.fn(); const mockCustomersRetrieve = vi.fn(); +const mockProductsRetrieve = vi.fn(); function makeDeps() { return { pool: { query: mockQuery } as any, stripe: { customers: { retrieve: mockCustomersRetrieve }, + products: { retrieve: mockProductsRetrieve }, } as unknown as Stripe, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), @@ -27,6 +29,7 @@ function makeDeps() { beforeEach(() => { mockQuery.mockReset(); mockCustomersRetrieve.mockReset(); + mockProductsRetrieve.mockReset(); }); function fakeOrg(overrides: Partial<{ @@ -34,6 +37,7 @@ function fakeOrg(overrides: Partial<{ subscription_status: string | null; subscription_canceled_at: Date | null; stripe_subscription_id: string | null; + membership_tier: string | null; subscription_price_lookup_key: string | null; subscription_amount: number | null; }> = {}) { @@ -42,21 +46,28 @@ function fakeOrg(overrides: Partial<{ return { workos_organization_id: 'org_x', stripe_customer_id: 'stripe_customer_id' in overrides ? overrides.stripe_customer_id : 'cus_x', + is_personal: false, subscription_status: 'subscription_status' in overrides ? overrides.subscription_status : null, subscription_canceled_at: overrides.subscription_canceled_at ?? null, stripe_subscription_id: 'stripe_subscription_id' in overrides ? overrides.stripe_subscription_id : null, + membership_tier: 'membership_tier' in overrides ? overrides.membership_tier : null, subscription_price_lookup_key: 'subscription_price_lookup_key' in overrides ? overrides.subscription_price_lookup_key : null, subscription_amount: 'subscription_amount' in overrides ? overrides.subscription_amount : null, + updated_at_token: '2026-08-19 12:00:00.123456+00', }; } -function fakeCustomerWithSubs(subs: unknown[]): Stripe.Customer { +function fakeCustomerWithSubs( + subs: unknown[], + stampedOrgId: string | null = 'org_x', +): Stripe.Customer { return { id: 'cus_x', deleted: false, + metadata: stampedOrgId ? { workos_organization_id: stampedOrgId } : {}, subscriptions: { data: subs }, } as unknown as Stripe.Customer; } @@ -66,6 +77,7 @@ function fakeMembershipSub(overrides: Partial<{ status: Stripe.Subscription.Status; lookup_key: string | null; unit_amount: number; + product: string; }> = {}) { return { id: overrides.id ?? 'sub_x', @@ -79,6 +91,7 @@ function fakeMembershipSub(overrides: Partial<{ currency: 'usd', recurring: { interval: 'year' }, lookup_key: 'lookup_key' in overrides ? overrides.lookup_key : 'aao_membership_professional_250', + product: overrides.product ?? 'prod_member', }, }], }, @@ -144,6 +157,66 @@ describe('attemptStripeReconciliation', () => { expect(updateParams).toContain('aao_membership_corporate_under5m'); }); + it('uses legacy membership product metadata to restore a zero-dollar founding tier', async () => { + mockQuery + .mockResolvedValueOnce({ + rows: [fakeOrg({ + subscription_status: null, + stripe_subscription_id: null, + membership_tier: null, + subscription_price_lookup_key: null, + subscription_amount: null, + })], + }) + .mockResolvedValueOnce({ rowCount: 1, rows: [{ workos_organization_id: 'org_x' }] }); + mockCustomersRetrieve.mockResolvedValueOnce( + fakeCustomerWithSubs([ + fakeMembershipSub({ lookup_key: null, unit_amount: 0, product: 'prod_founding' }), + ]), + ); + mockProductsRetrieve.mockResolvedValueOnce({ + id: 'prod_founding', + deleted: false, + name: 'Founding membership', + metadata: { category: 'membership', tier: 'company_standard' }, + }); + + const result = await attemptStripeReconciliation('org_x', makeDeps()); + + expect(result.healed).toBe(true); + expect(mockProductsRetrieve).toHaveBeenCalledWith('prod_founding'); + expect(mockQuery.mock.calls[1][1]).toContain('company_standard'); + }); + + it('preserves an existing tier and product fields when Stripe omits canonical metadata', async () => { + mockQuery + .mockResolvedValueOnce({ + rows: [fakeOrg({ + subscription_status: 'canceled', + stripe_subscription_id: 'sub_x', + membership_tier: 'company_standard', + subscription_price_lookup_key: 'aao_membership_legacy', + subscription_amount: 25000, + })], + }) + .mockResolvedValueOnce({ rowCount: 1, rows: [{ workos_organization_id: 'org_x' }] }); + mockCustomersRetrieve.mockResolvedValueOnce( + fakeCustomerWithSubs([ + fakeMembershipSub({ lookup_key: 'aao_membership_unmapped', unit_amount: 0 }), + ]), + ); + + const result = await attemptStripeReconciliation('org_x', makeDeps()); + + expect(result.healed).toBe(true); + const [updateSql, updateParams] = mockQuery.mock.calls[1] as [string, unknown[]]; + expect(updateSql).toContain( + 'subscription_product_name = COALESCE($9, subscription_product_name)', + ); + expect(updateSql).toContain('subscription_amount = COALESCE($4, subscription_amount)'); + expect(updateParams[11]).toBe('company_standard'); + }); + it('heals when status=active but only stripe_subscription_id is missing (lookup_key set)', async () => { // Less common partial state, but still a sync gap. Either field missing // means lazy-reconcile should fill it in. @@ -164,6 +237,33 @@ describe('attemptStripeReconciliation', () => { expect(result.healed).toBe(true); }); + it('heals a fully populated row whose canceled status is stale', async () => { + mockQuery + .mockResolvedValueOnce({ + rows: [fakeOrg({ + subscription_status: 'canceled', + stripe_subscription_id: 'sub_x', + membership_tier: 'company_standard', + subscription_price_lookup_key: 'aao_membership_professional_250', + subscription_amount: 25000, + })], + }) + .mockResolvedValueOnce({ rowCount: 1, rows: [{ workos_organization_id: 'org_x' }] }); + mockCustomersRetrieve.mockResolvedValueOnce(fakeCustomerWithSubs([fakeMembershipSub()])); + + const result = await attemptStripeReconciliation('org_x', makeDeps()); + + expect(result).toEqual({ + healed: true, + reason: 'healed_from_stripe', + subscriptionStatus: 'active', + }); + expect(mockQuery.mock.calls[1][0]).toContain("subscription_status NOT IN ('active', 'trialing', 'past_due')"); + expect(mockQuery.mock.calls[0][0]).toContain('updated_at::text AS updated_at_token'); + expect(mockQuery.mock.calls[1][0]).toContain('updated_at = $14::timestamptz'); + expect(mockQuery.mock.calls[1][1].at(-1)).toBe('2026-08-19 12:00:00.123456+00'); + }); + it('skips when org has no Stripe customer id', async () => { mockQuery.mockResolvedValueOnce({ rows: [fakeOrg({ stripe_customer_id: null })] }); @@ -182,6 +282,26 @@ describe('attemptStripeReconciliation', () => { expect(result).toEqual({ healed: false, reason: 'customer_deleted' }); }); + it.each([ + ['missing', null], + ['mismatched', 'org_other'], + ])('refuses to heal when Stripe customer org metadata is %s', async (_label, stampedOrgId) => { + mockQuery.mockResolvedValueOnce({ rows: [fakeOrg()] }); + mockCustomersRetrieve.mockResolvedValueOnce( + fakeCustomerWithSubs([fakeMembershipSub()], stampedOrgId), + ); + const deps = makeDeps(); + + const result = await attemptStripeReconciliation('org_x', deps); + + expect(result).toEqual({ healed: false, reason: 'customer_org_mismatch' }); + expect(mockQuery).toHaveBeenCalledTimes(1); + expect(deps.logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ orgId: 'org_x', stampedOrgId }), + expect.stringContaining('metadata mismatch'), + ); + }); + it('skips when Stripe customer has no membership sub', async () => { mockQuery.mockResolvedValueOnce({ rows: [fakeOrg()] }); mockCustomersRetrieve.mockResolvedValueOnce( diff --git a/static/schemas/source/enums/match-id-type.json b/static/schemas/source/enums/match-id-type.json index 6fc5620f0f..63ec930524 100644 --- a/static/schemas/source/enums/match-id-type.json +++ b/static/schemas/source/enums/match-id-type.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/match-id-type.json", "title": "Match ID Type", - "description": "Identifier types for audience match reporting. Combines hashed PII types (from audience-member.json field names) with universal ID types (from uid-type.json).", + "description": "Identifier types for audience match reporting. Combines hashed PII types (from audience-member.json field names) with token types from uid-type.json, including publisher-scoped PAIR identifiers.", "type": "string", "enum": [ "hashed_email", @@ -22,7 +22,7 @@ "id5": "ID5 universal ID", "uid2": "Unified ID 2.0", "euid": "European Unified ID", - "pairid": "Publisher Addressable Identity via IAB Tech Lab PAIR protocol", + "pairid": "Publisher Addressable Identity via IAB Tech Lab PAIR. In OpenRTB, User.eids[].source is pair-protocol.com and uids[].atype is 3. The wire token is a Base64-encoded, encrypted KsKp Publisher Identifier scoped to the publisher owner represented by EID.matcher; a publisher uses the same identifier for a user across its advertiser matches, but identifiers are not comparable across publisher scopes. A DSP matches the token only against advertiser-authorized, publisher-specific PAIR lists produced by the PAIR DCR flow.", "maid": "Mobile Advertising ID (IDFA/GAID)", "other": "Other identifier type (specify in ext)" } diff --git a/static/schemas/source/enums/uid-type.json b/static/schemas/source/enums/uid-type.json index e2cb694963..b2a7e2ddc2 100644 --- a/static/schemas/source/enums/uid-type.json +++ b/static/schemas/source/enums/uid-type.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/uid-type.json", "title": "UID Type", - "description": "Type of user identifier. Used in audience sync, event logging, and TMP identity match requests to tell the receiver which identity graph to resolve against.", + "description": "Type of user identifier. Used in audience sync, event logging, and TMP identity match requests to tell the receiver how to interpret and resolve the identifier; some types, including PAIR, are publisher-scoped rather than universal identity-graph tokens.", "type": "string", "enum": [ "rampid", @@ -23,7 +23,7 @@ "id5": "ID5 universal ID", "uid2": "Unified ID 2.0", "euid": "European Unified ID", - "pairid": "Publisher Addressable Identity via IAB Tech Lab PAIR protocol", + "pairid": "Publisher Addressable Identity via IAB Tech Lab PAIR. In OpenRTB, User.eids[].source is pair-protocol.com and uids[].atype is 3. The wire token is a Base64-encoded, encrypted KsKp Publisher Identifier scoped to the publisher owner represented by EID.matcher; a publisher uses the same identifier for a user across its advertiser matches, but identifiers are not comparable across publisher scopes. A DSP matches the token only against advertiser-authorized, publisher-specific PAIR lists produced by the PAIR DCR flow.", "maid": "Mobile Advertising ID (IDFA/GAID)", "hashed_email": "SHA-256 hashed email address. Pseudonymous PII, not anonymous — unsalted SHA-256 of the email namespace is recoverable via precomputed dictionaries; treat as PII. See docs/reference/privacy-considerations#unsalted-hashed-identifiers-are-pseudonymous-not-anonymous.", "publisher_first_party": "Publisher's own first-party identifier, opaque to buyers",