spec(tmp): experimental verified-identity attestation surface (World ID)#5387
spec(tmp): experimental verified-identity attestation surface (World ID)#5387bokelley wants to merge 4 commits into
Conversation
Adds a verifiable proof-of-personhood / age surface to TMP Identity Match (trusted_match.verified_identity) so buyers verify identity claims instead of trusting an assertion. Issuer-agnostic; World ID is the first scheme. - uid-type: +world_id_nullifier - new attestation-claim enum (unique_human, age_over_13/16/18/21) - identity-match-request: optional per-identity `attestation` + top-level `sealed_credentials[]` (deliberate widening of the additionalProperties:false privacy boundary — proof about the identity, not page context) - brand.json: identity_relying_parties[] on house + brand (rp_id provenance) - specification.mdx: Verified Identity Attestation section (topologies, conformance invariants, router sealed_credentials handling, age-as-eligibility) - experimental-status: trusted_match.verified_identity feature id Design: specs/tmp-verified-identity-attestation.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
There was a problem hiding this comment.
Additive, experimental, and contract-honest — minor is the right changeset for a surface that only adds optional fields, a new enum value, and a new enum. The architectural call is right: forward the raw ZK proof so the receiver runs the crypto itself, with no trusted attester in the loop — verifiable beats asserted, and the rp_id-as-linkability-boundary framing is the correct shape.
Things I checked
- Changeset semver.
minoris correct. No existing field renamed, retyped, or flipped required↔optional; no enum value removed.world_id_nullifieris appended touid-type,attestation-claim.jsonis net-new, andattestation/sealed_credentials[]are new optional containers. The innerrequiredarrays (issuer/scheme/claims/proof,audience_kid/payload,issuer/rp_id) sit inside optional parents, so they impose nothing on existing senders.ad-tech-protocol-expert: sound-with-caveats. - No oneOf regression. Zero
oneOfadded anywhere in the diff;scripts/audit-oneof.mjsonly walksoneOfnodes, so a scalar enum and plain objects are invisible to it. Baseline can't move. - The
additionalProperties: falsewidening is on the wire's own terms. Both new keys are explicit named properties on the identity side of the boundary — not anext/contextescape hatch — so the privacy boundary the strict schema protects is intact.proofis correctlyadditionalProperties: true(opaque scheme material) inside an otherwise-closed envelope.x-status: experimentalcarries the contract-bearing note. - Schema↔docs coherence.
specification.mdxAttestation table required-ness (issuer/scheme/claims/proof = Yes, rest = No) matchesidentity-match-request.jsonrequiredexactly; SealedCredential table matches;verification_levelenumorb|device|documentmatches both ways. No drift. - enumDescriptions parity.
attestation-claim.json5/5,uid-typeaddition 1:1. - brand.json
$refresolves.#/definitions/identity_relying_partyis referenced from bothhouseandbrandand resolves as a sibling underdefinitions. - Age stays off the wire.
attestation-claim.jsonis a closed threshold-only enum; jurisdiction tables live in the Policy Registry and resolve toeligible_package_ids;countryis stripped before forwarding. No DOB/exact-age leak path.security-reviewer: confirmed clean. - sealed_credentials routing. Route-by-
audience_kid(not broadcast) + fold into per-provider re-signature canonical bytes +sealed_credentials_hashin the dedup key closes injection, swap, and broadcast-leak.security-reviewer: strongest part of the surface, no gap.
Follow-ups (non-blocking — file as issues, must close before any party advertises trusted_match.verified_identity for production traffic)
- Replay-binding fields are optional on the wire.
signal_bindingandexpires_atare not inattestation.required(identity-match-request.json:563-568), so a proof with zero replay binding is schema-valid — yet the design spec concedes atspecs/tmp-verified-identity-attestation.md:195that proof verification alone does not stop replay. The MUST lives in prose (specification.mdx:105) while the wire stays permissive. Consider makingsignal_binding+expires_atrequired (orverification_level-gated) and pinning the nullifier-reuse window normatively rather than leaving it as the open "Replay window policy" question. (security-reviewer: Medium.) - DoS bound is prose-only.
identities[]carriesmaxItems: 3, butsealed_credentials[]has nomaxItemsandattestation.proofisadditionalProperties: truewith no size cap. "Receivers MUST bound count and size" gives every implementer a different floor. Put a generousmaxItemsonsealed_credentials[]and amaxLength/maxPropertiesonproof/payloadon the wire. (security-reviewer: Medium.) - rp_id provenance is one-sided until the issuer cross-check lands. The
brand.json identity_relying_parties[]check stops a relay forwarding under another owner'srp_idonly becausebrand.jsonis self-published — the bidirectional issuer-metadata cross-check is still an open question (specs/tmp-verified-identity-attestation.md:236). Acceptable for v1; document it as a GA precondition.
Minor nits (non-blocking)
- No bound on the brand.json arrays.
identity_relying_parties[]on bothhouseandbrand(brand.json:382,:395) has nomaxItems, in a PR whose whole security story is bounding identity-bearing arrays. Cheap to add. scoperequired-ness. The sketch atspecs/tmp-verified-identity-attestation.md:224showsscopepopulated; the schema makes it optional. No conflict, just confirm that's intended given the field sets cross-property linkability.
One dry observation: the design doc spends a paragraph arguing signal_binding is the load-bearing replay defense, then ships it optional on the wire and files the freshness policy under Open Questions. The reasoning and the schema should agree before this leaves experimental.
Approving on the strength of the additive-only wire shape plus a coherent schema/docs/changeset. Follow-ups noted; none block the merge.
- Wire sealed_credentials into the canonical Identity Match signed-fields table + dedup cache key (sealed_credentials_hash with a JCS preimage) and the router-architecture re-sign step — the prose MUSTs now match the authoritative tables, fixing the signing/cache split-brain (blocker). - State that the canonical identities bytes serialize the full identity object including per-identity attestation, so the router signature covers it (strip/swap/inject breaks verification). - Add a conditional age-eligibility clause to the IdentityMatch conformance predicate, gated on trusted_match.verified_identity. - Bound DoS vectors: sealed_credentials maxItems, payload/audience_kid maxLength; claims minItems/uniqueItems/maxItems; proof-opacity note. - Note the v1 replay limitation and that brand.json rp_id provenance is a cross-check, not the authoritative root (the issuer registry is). - Normalize "AdCP Policy Registry" naming; mark the design doc shipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expert review pass (protocol / security / product / docs)Ran this through four review lenses + an adversarial synthesis that verified each finding against the diff. Verdict: mergeable as experimental, one true blocker (now fixed). Addressed in Blocker (B1) + related (N1) — fixed:
Mediums — fixed:
Nits — fixed: normalized "AdCP Policy Registry" naming; Dropped after verification: the "property-level Deliberately deferred (low): reciprocal |
There was a problem hiding this comment.
Clean additive experimental surface — attestation + sealed_credentials[] ride the Identity Match request under a separately-detectable trusted_match.verified_identity feature id. This is the right shape: it widens an additionalProperties:false schema on the identity side of the privacy boundary (proof about the identity, not page context), so the boundary the strict schema protects is intact.
Things I checked
- Schema↔docs coherence. Attestation
required: ["issuer","scheme","claims","proof"](static/schemas/source/tmp/identity-match-request.json:612-617) matches the docs table Required column exactly (docs/trusted-match/specification.mdx:101-111);verification_levelenumorb|device|documentmatches; SealedCredentialrequired: ["audience_kid","payload"]matchesspecification.mdx:117-120. No rename, no type drift, no required-flip.ad-tech-protocol-expert: sound. - Changeset type.
.changeset/tmp-verified-identity-attestation.md:8isminor. Correct — every addition is optional (attestationnon-required on identity items,sealed_credentialsnon-required top-level, both enum changes are appends,identity_relying_parties[]optional on house+brand). An old validator still accepts old payloads; nomajorowed. - Enum additions.
world_id_nullifierappended beforeotherin bothenumandenumDescriptions(enums/uid-type.json) — parity holds. Newattestation-claim.jsonis draft-07,$idmatches theindex.jsonregistration path, all five members have matchingenumDescriptions. The disambiguation note vs theworld_idvalue inage-verification-methodheads off a real collision. - No oneOf regression. IMR schema carries no
oneOf/anyOf/allOf;identity_relying_partyis a flat closed object (required: ["issuer","rp_id"],additionalProperties:false) referenced from two sites — both$refs resolve. Publisher-as-RP vs network-as-RP is modeled as two independent optional carriers, not a discriminated union. Right call. - Design doc (
specs/tmp-verified-identity-attestation.md, 246 lines — largest non-generated file). Theworld_id_nullifier-not-publisher_first_partyrationale at L250-254 is load-bearing and correct: tagging a Sybil-resistant nullifier as a churn-able first-party cookie discards the one property that makes it valuable. The control-plane-vs-data-plane split against XAA / RFC 8693 (L373-389) is the correct adjacent standard and keeps OAuth token-endpoint round trips off the per-impression path. - Sealed-credential tamper/cache.
sealed_credentialsfolded into the per-provider re-signature canonical bytes andsealed_credentials_hashin the dedup cache key (specification.mdx:156,165) — swap/inject breaks the signature, a credential change repartitions the cache, route-by-audience_kidkeeps payloads opaque to non-owning providers.security-reviewer: no tamper or poisoning gap.
Follow-ups (non-blocking — file as issues before trusted_match.verified_identity leaves experimental)
proofis unbounded in size.identity-match-request.json:602-606—proofis{type:object, additionalProperties:true}with nomaxProperties/maxLength.identitiescaps attestation count, but eachproofblob is unbounded. The spec's "Receivers MUST bound attestation size" (specification.mdxconformance invariant 5) is load-bearing but prose-only — a schema-only implementer gets zero DoS protection. Contrastsealed_credentials.payloadwhich is bounded at 8192. Boundproofthe same way before GA. (security-reviewerM1, Medium.)- Replay fails open by schema default.
signal_bindingandexpires_atare optional (identity-match-request.json:612-617). The fail-closed language is strong for an attestation that carries those fields and fails the check, but one that simply omits them passes validation — so the documented v1 floor is daily-epoch replay only. Disclosed honestly as a v1 limitation (specification.mdxReplay note), which is why it's a follow-up and not a block underx-status: experimental. For the personhood path where replay is the whole value proposition, makesignal_binding+expires_atrequired before GA. (security-reviewerM2, Medium.) - rp_id provenance bidirectional cross-check is a tracked open item — the spec correctly states
brand.jsonalone MUST NOT be trusted absent an issuer-side anchor (specification.mdxconformance invariant 3). Land the issuer-metadata cross-check before GA. proof: additionalProperties:trueas a context-smuggling vector is adequately fenced today (the object is JCS-serialized into the signedidentities_hash, and the only adversary who could exploit it — a malicious publisher — already originates both sides). Whenschemebecomes a registry, replace the openproofwith per-scheme closed shapes so the boundary is structurally enforced, not prose-enforced.
Minor nits (non-blocking)
- Dead
maxItems.identity-match-request.json:587—claims.maxItems: 16against a 5-memberuniqueItems:trueenum means the effective ceiling is 5. Harmless but misleading; lower to 5 or drop it. - Docs table omits a
claimsconstraint. The Attestation table doesn't noteminItems:1onclaimswhile the schema enforces it. Doc-completeness only.
Three experts, zero blockers. The two security findings are Medium, schema-enforceable, and gated behind the experimental flag — exactly the kind of thing the WG-open list exists to track. The PR is unusually honest about its own v1 limitations, which is the right posture for shipping a contract-bearing surface under x-status: experimental.
Approving on the strength of the tight schema↔docs mapping plus the fail-closed conformance language.
Address review on the verified-identity attestation surface.
issuer: model the identity issuer as a vendor — the same `core/brand-ref.json`
shape AdCP uses for measurement/signals vendors. `issuer: "world_id"` (opaque
string) becomes `issuer: {"domain": "world.org"}` on both wire sides. The
domain is the canonical anchor; brand.json hosting is optional. The relying
party is namespaced by the issuer: `(issuer.domain, issuer.brand_id,
relying_party_id)`, mirroring `(vendor.domain, vendor.brand_id, metric_id)`.
rp_id -> relying_party_id: spell out the OIDC/WebAuthn abbreviation to match
AdCP's id naming and its own `identity_relying_parties[]` container; not
`entity_id` (no generic entity type, and one entity runs many relying
parties). Tag `x-entity: identity_relying_party` so it obeys x-entity
discipline. The BrandRef issuer field carries no x-entity (the domain is the
identity), matching how the `vendor` field is done.
Renamed atomically across both wire surfaces, the spec conformance clauses,
the design doc's AdCP-field JSON examples, and this changeset; World ID's
native `rp_id` concept (nullifier scope) and the prototype are left as-is.
build:schemas, context-entity lint, test:schemas, and test:json-schema pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Clean additive experimental extension to TMP Identity Match. The architecture is right: an attestation is proof about the identity, so widening the additionalProperties: false identity-match-request with optional fields stays on the identity side of the privacy boundary — it doesn't breach the boundary the strict schema protects.
Things I checked
- The signing/cache split-brain commit 2 claims to fix is genuinely closed. All three
sealed_credentials_hashreferences are mutually consistent: signed-fields table (specification.mdx:541), canonical-bytes definition (:547— sort byaudience_kidUTF-8, JCS, SHA-256,nullwhen absent), and dedup cache key (:730), plus the router re-sign step (router-architecture.mdx:46). Per-identityattestationis folded intoidentities_hashvia full-object JCS, so it rides the existing signature without a parallel row. No normative MUST lives in prose without a backing canonical-table entry — which was the exact bug class. rp_id→relying_party_idrename is atomic. Zero strayrp_idin any AdCP-field context acrossbrand.json,identity-match-request.json,x-entity-types.json, and the docs. The remainingrp_idoccurrences are confined tospecs/tmp-verified-identity-attestation.mdnarrative and refer to World ID's native nullifier-scope concept — correctly left as-is.- x-entity wiring complete on both halves.
relying_party_idcarriesx-entity: identity_relying_partyon both wire surfaces and is registered inx-entity-types.jsonenum + definitions.issueras a BrandRef correctly carries no x-entity (the domain is the identity), matching thevendorpattern. Identity tuple(issuer.domain, issuer.brand_id, relying_party_id)mirrorsvendor_metric's(vendor.domain, vendor.brand_id, metric_id)—ad-tech-protocol-expert: sound, no new vendor-reference primitive invented. - Enum parity.
attestation-claim.jsonis 1:1 — all five members have matchingenumDescriptions, no orphans; registered inindex.json;world_id_nullifierhas a substantive description inuid-type.jsonand is appended before theothersentinel (non-breaking append position). Distinct from the unrelatedworld_idvalue inage-verification-method(a method, not an identifier) — the schema calls that out explicitly. - Docs↔schema coherence.
docs-expert: no drift. The Attestation and SealedCredential tables inspecification.mdxmatch the schema field-for-field on type and required/optional; anchors resolve; theexperimental-status.mdxfeature-surface row matches what shipped. - Fail-closed posture.
security-reviewer: no path where an unverified attestation reads as asserted-true — invariants 1–2 plus the age-eligibility clause ("unverified or absent attestation does not satisfy this clause"; vacuously true when the feature is off) leave no silent upgrade. Sealed routing is route-by-audience_kid, not broadcast; inject/swap/strip breaks the re-signature. Age crosses the wire only as a threshold claim, never as cleartext DOB.
Follow-ups (non-blocking — file as issues)
attestation.proofis the one unbounded field amid carefully-bounded neighbors.security-reviewerMedium:proofistype: object, additionalProperties: truewith nomaxPropertiesor byte cap, whilesealed_credentials(maxItems 8),payload(maxLength 8192),audience_kid(maxLength 128), andidentities(maxItems 3) are all explicitly bounded. The design doc mandates it in prose —specs/tmp-verified-identity-attestation.md:285: "Receivers MUST bound attestation size and count to prevent DoS amplification" — but the schema lets an oversizedproofthrough. An implementer trusting the schema for input bounds (the normal assumption on a strictadditionalProperties:falsesurface) ships unbounded on the verify path. Add amaxPropertiesor serialized-size cap to match the prose MUST. Experimental, so a follow-up, not a block.- Provenance: the load-bearing MUST is phrased as an open item. Invariant 3 says brand.json is the v1 discovery surface and the issuer's own registry (e.g. World ID on-chain) is the authoritative root — but the issuer-anchor requirement is deferred as a "tracked open item" while the field table says
relying_party_idis "Checked against ... brand.json." An implementer reading only invariant 3 + the table may ship brand.json-only provenance, which is exactly the replay-under-a-different-owner gap the design doc raises. When the freshness/registry policy lands, promote "issuer-registry anchor required before trusting a cross-entityrelying_party_id" to a hard normative MUST. signal_bindingandexpires_atare optional in schema. Not in the attestationrequiredset, so a freshness-free attestation parses valid. The replay limitation is honestly disclosed ("at most daily-epoch replay resistance"), so no over-promise — but when the WG-open freshness policy resolves, make both required for any attestation used to satisfy the eligibility clause.- Changeset type.
ad-tech-protocol-expertreads the experimental-surface downgrade as making thispatchrather thanminor. The diff also touches stable schemas (uid-type.json,brand.json,index.json) additively, sominoris defensible — but it's a categorization nuance worth a second look against the triage matrix. Nothing breaking here either way, so not a block.
Minor nits (non-blocking)
claims.maxItems: 16can never bind.identity-match-request.json— theattestation-claimenum has five values anduniqueItems: truealready caps the array at five. A bound that can't bind is harmless but misleading; set it to5or drop it.
Approving on the strength of the closed signing/cache split-brain plus clean schema↔docs parity. Follow-ups noted — the unbounded proof is the one worth filing.
…ation The brand-canonical-tools validator compiles brand.json with a hand-rolled ajv ref preloader that walked only brand.json's own $refs. That held while every brand.json ref pointed at leaf enum schemas, but the verified-identity work made `identity_relying_party.issuer` a `core/brand-ref.json` BrandRef, and brand-ref.json itself refs brand-id.json / image-asset.json / ext.json — none of which got registered, so ajv threw "can't resolve reference /schemas/core/brand-id.json from id /schemas/core/brand-ref.json" and the 8 brand-canonical-tools tests failed. Recurse into each loaded schema so its own cross-file $refs register transitively; the existing `seen` set dedupes and breaks cycles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Approving. Additive, experimental, and the widening of the additionalProperties:false identity boundary is justified on the boundary's own terms — proof about the identity is on the identity side of it, not page context.
Things I checked
- Changeset is correct.
minoris the right bump. Every new field is optional (attestationis not inidentities[].items.required,sealed_credentialsis not in the top-levelrequired), theuid-typeenum gainsworld_id_nullifierwithout removing or renaming a value, and widening anadditionalProperties:falseschema is strictly permissive — every payload valid under the old schema is still valid. Nomajorowed.ad-tech-protocol-expert: sound. - Schema-vs-docs coherence is exact. Field-by-field, the spec.mdx Attestation table (required
issuer, scheme, claims, proof; optionalrelying_party_id, action, verification_level, signal_binding, expires_at) matchesidentity-match-request.jsonattestation.required. SealedCredential (audience_kid,payloadboth required) matches.world_id_nullifier, theattestation-claimenum, andidentity_relying_parties[]are all documented inexperimental-status.mdx. No drift either direction. - enumDescriptions parity. All 5 values in
enums/attestation-claim.jsondescribed; the newuid-typevalue described. No undiscriminatedoneOfintroduced — the publisher-as-RP / network-as-RP split is two independent optional properties, not a discriminated union. - Entity registration is consistent.
relying_party_idcarriesx-entity: identity_relying_partyin bothbrand.jsonandidentity-match-request.json, andcore/x-entity-types.jsonregisters it in both the type list andx-entity-definitions. Both ends covered. - The recursion fix is safe.
brand-canonical-tools.ts:102—visit(referencedSchema)cannot infinite-loop:seen.add(ref)runs before the recursive walk and the load branch is gated on!seen.has(ref), so depth is bounded by the count of distinct$refstrings. Necessary becausebrand.jsonnow reachescore/brand-ref.jsonand its transitive refs.code-reviewer: clean, no blockers. - DoS bounds are present on the sealed path.
sealed_credentialsmaxItems 8,audience_kidmaxLength 128,payloadmaxLength 8192;identitiesmaxItems 3;claimsmaxItems 16. Fail-closed conformance is normative (specification.mdx:124-132): unverifiable attestation → treated as absent, never asserted-true; reject on failedsignal_binding/relying_party_idprovenance /expires_at. - Provenance is honestly fenced.
specs/tmp-verified-identity-attestation.md:299and specification.mdx:128 both state self-publishedbrand.jsonis insufficient, the issuer registry is the authoritative root, and the bidirectional cross-check is a tracked open item. Therp_id-spoofing path is named, not papered over.
Follow-ups (non-blocking — file as issues)
- Cap
proofand the free-string fields at the schema, not just in prose.security-reviewerflagged this Medium:proofisadditionalProperties: truewith nomaxProperties/byte cap, andscheme/relying_party_id/action/signal_bindinghave nomaxLength. The sealed path got a byte budget (payloadmaxLength 8192); the in-band attestation path is still asking for one in prose ("Receivers MUST bound attestation size"). Same opaque-blob concern, two different enforcement tiers. Mirror the sealed-credential discipline. Also closes the residual smuggling channel — an uncapped opaqueproofis where a non-conformant publisher could stuff a context-correlation tag back across the boundary the strict schema advertises. signal_bindingfreshness,schemeregistry-vs-free-form, andbrand.jsonvariant placement are all correctly flagged WG-open inexperimental-status.mdx— leaving them under experimental is the right call.
Minor nits (non-blocking)
- Changeset wording.
.changeset/tmp-verified-identity-attestation.mdand the PR body describebrand-ref.jsonas$ref-ingimage-asset.json/ext.jsondirectly; it actually refscore/assets/image-asset.jsonand reachesextonly transitively. Wording only — no schema or code impact.
Test plan is honest — build/schema suites green and the wire shape exercised against live World ID v4 in a prototype; no unchecked manual box against the path this changes.
LGTM. Follow-ups noted below.
What
Adds an experimental verified-identity attestation surface to TMP Identity Match (
trusted_match.verified_identity): a publisher — or a network/issuer acting as the relying party — can forward a verifiable proof about a user (proof-of-personhood and/or age) so the buyer verifies the claim cryptographically instead of trusting an assertion. Issuer-agnostic; World ID is the first scheme.Changes (additive, experimental)
enums/uid-type.json—+world_id_nullifier(Sybil-resistant, rp-scoped, unlinkable pseudonym; asserts nothing on its own — trust comes from the attestation).enums/attestation-claim.json(new) — closed claim set:unique_human,age_over_13/16/18/21.tmp/identity-match-request.json— optional per-identityattestationobject + top-levelsealed_credentials[](network-as-RP carrier).brand.json—identity_relying_parties[]on house (entity) and brand (property) for rp_id provenance.index.json— register the new enum.specification.mdxVerified Identity Attestation section (topologies, conformance invariants, routersealed_credentialshandling, age-as-eligibility) +experimental-status.mdxfeature id.Contract-bearing note
identity-match-request.jsonisadditionalProperties: falseon purpose (the identity privacy boundary). These fields are a deliberate widening — proof about the identity (identity side of the boundary), not page context. Shippedx-status: experimental; not subject to deprecation cycles until 3.0.0 GA.Validation
build:schemas,test:schemas,test:json-schema,test:build-schemas-hoist-enums,test:schema-utf8,test:schema-linksall green; precommit (unit + typecheck) passed. The wire shape was also exercised end-to-end against the live World ID v4 API in a prototype (signed proof-request → verify → eligibility).Open questions (flagged in experimental-status)
signal_bindingfreshness policy, issuer/scheme registry-vs-free-form, andbrand.jsonvariant placement are WG-open.Design rationale:
specs/tmp-verified-identity-attestation.md.🤖 Generated with Claude Code