fix: separate resource binding from participant authorization - #762
Merged
prasad-stripe merged 1 commit intoAug 24, 2026
Merged
Conversation
A token is bound to a resource and issued to a participant. Those need different checks — the resource is a replay guard compared for exact equality, the participant is an authorization question. #746 moves `identity` out of the `binding` object but rule 1 still compares it. That can't work, because `identity` depends on who is calling: a business omits it when detokenizing directly, its PSP includes it when acting on the business's behalf (platform-tokenizer L288 and L487). Same token, two legal shapes, so equality over it passes at most one of them. - Rule 1 compares `type` and `id` only, and ignores unrecognized members rather than rejecting or comparing them. `binding` is an open object now and nothing else defines this. - Rule 2: a tokenizer must not *depend* on `binding.id` resolving, but may check locally if it owns the resource. - Rule 4 (new): record the participant at mint, verify it at burn. - `/detokenize` told callers to omit `identity` "when it was omitted at tokenization"; the prose says omit it when you are the target. Those disagree — fixed to match the prose. - `minLength: 1` on `binding.id`.
17 tasks
prasad-stripe
approved these changes
Aug 24, 2026
prasad-stripe
added a commit
that referenced
this pull request
Aug 24, 2026
Follow-ups to #762 across the payment specification. The payment guide still described binding as an association to a checkout, in the Key Definitions entry, the instrument acquisition input table, the handler authoring guidance, and the security best practice. Binding is to a capability resource identified by `type` and `id`. The definition also folded business identity into binding, which is now the separate participant axis. The platform tokenizer example needed the same split. Binding verification now names the requesting participant rather than "caller identity", and the requirement labelled "Identity binding" becomes "Issued to participant". The row below it, "Resource-bound", is then the only one using bound, which is correct. Two tokenizer rules are adjusted from #762. Rule 1 no longer declares that members other than `type` and `id` fall outside the replay guard: a Tokenizer MUST NOT reject unrecognized members and MUST ignore them when comparing, but MAY compare members defined by an extension it implements, so an extension that scopes a binding can still mean something. Rule 4 now states that authority for one participant to act for another is handler-defined and outside this specification, which the MUST otherwise leaves ungrounded. Markdown tables reflowed to the file convention where new text changed column widths. Rule adjustments in response to review by @igrigorik.
prasad-stripe
merged commit Aug 24, 2026
6f4e1eb
into
prasad-stripe/polymorphic-binding
7 of 8 checks passed
prasad-stripe
added a commit
that referenced
this pull request
Aug 24, 2026
Follow-ups to #762 across the payment specification. The payment guide still described binding as an association to a checkout, in the Key Definitions entry, the instrument acquisition input table, the handler authoring guidance, and the security best practice. Binding is to a capability resource identified by `type` and `id`. The definition also folded business identity into binding, which is now the separate participant axis. The platform tokenizer example needed the same split. Binding verification now names the requesting participant rather than "caller identity", and the requirement labelled "Identity binding" becomes "Issued to participant". The row below it, "Resource-bound", is then the only one using bound, which is correct. Two tokenizer rules are adjusted from #762. Rule 1 no longer declares that members other than `type` and `id` fall outside the replay guard: a Tokenizer MUST NOT reject unrecognized members and MUST ignore them when comparing, but MAY compare members defined by an extension it implements, so an extension that scopes a binding can still mean something. Rule 4 now states that authority for one participant to act for another is handler-defined and outside this specification, which the MUST otherwise leaves ungrounded. Markdown tables reflowed to the file convention where new text changed column widths. Rule adjustments in response to review by @igrigorik.
prasad-stripe
added a commit
that referenced
this pull request
Aug 24, 2026
* refactor!: make token binding vertical-agnostic PR #741 moves the payment constructs to `common/` but keeps `binding.json` in `shopping/types` because it references checkout sessions. That leaves the tokenization handler contract, which lives in `source/handlers/` and is horizontal by construction, requiring a `checkout_id` on a required field of both `/tokenize` and `/detokenize`. A non-shopping capability cannot call either endpoint without fabricating a checkout identifier. The same file already refs the credential from `common/`, so the asymmetry is only in binding. Binding becomes a flat `{type, id}` pair in `common/types/binding.json`: - `type` is the capability that owns the bound resource, refs `reverse_domain_name.json`, e.g. `dev.ucp.shopping.checkout`. - `id` is the opaque identifier of that resource within the capability. Resource scope and participant scope are now separate. `identity` moves out of `binding` to a sibling field on the `/tokenize` and `/detokenize` request bodies: `binding` says which resource the token is for, `identity` says which participant it is for. `payment_identity.json` itself is unchanged; only its position in the request moves. The tokenization handler refs both schemas, exactly as it already refs the base credential. One binding shape covers every capability, so no per-vertical binding schema is needed and SDKs keep a single model. Tokenizers need no new logic. Binding was never semantically validated (a tokenizer cannot confirm a checkout exists), so it is an opaque equality token. The guide now states three normative rules that make this explicit: verification is exact equality over the binding object and the identity presented with it, `binding.id` is opaque and MUST NOT be parsed or resolved, and a tokenizer MUST NOT reject a request solely because it does not recognize `binding.type`. Those rules are what close the cross-type confusion this generalization would otherwise introduce. BREAKING CHANGE: `https://ucp.dev/schemas/shopping/types/binding.json` moves to `https://ucp.dev/schemas/common/types/binding.json`, `checkout_id` is replaced by `type` plus `id`, and `identity` moves from inside `binding` to a sibling request field. Checkout bindings become `{"type": "dev.ucp.shopping.checkout", "id": "<checkout_id>"}`. Requesting this rides #741's release so implementers absorb both moves at once. Binding shape credit to @gsmith85, who proposed the flat form in review. Identity separation credit to @raginpirate. * fix: separate resource binding from participant authorization A token is bound to a resource and issued to a participant. Those need different checks — the resource is a replay guard compared for exact equality, the participant is an authorization question. #746 moves `identity` out of the `binding` object but rule 1 still compares it. That can't work, because `identity` depends on who is calling: a business omits it when detokenizing directly, its PSP includes it when acting on the business's behalf (platform-tokenizer L288 and L487). Same token, two legal shapes, so equality over it passes at most one of them. - Rule 1 compares `type` and `id` only, and ignores unrecognized members rather than rejecting or comparing them. `binding` is an open object now and nothing else defines this. - Rule 2: a tokenizer must not *depend* on `binding.id` resolving, but may check locally if it owns the resource. - Rule 4 (new): record the participant at mint, verify it at burn. - `/detokenize` told callers to omit `identity` "when it was omitted at tokenization"; the prose says omit it when you are the target. Those disagree — fixed to match the prose. - `minLength: 1` on `binding.id`. * docs: align binding vocabulary and tighten tokenizer rules Follow-ups to #762 across the payment specification. The payment guide still described binding as an association to a checkout, in the Key Definitions entry, the instrument acquisition input table, the handler authoring guidance, and the security best practice. Binding is to a capability resource identified by `type` and `id`. The definition also folded business identity into binding, which is now the separate participant axis. The platform tokenizer example needed the same split. Binding verification now names the requesting participant rather than "caller identity", and the requirement labelled "Identity binding" becomes "Issued to participant". The row below it, "Resource-bound", is then the only one using bound, which is correct. Two tokenizer rules are adjusted from #762. Rule 1 no longer declares that members other than `type` and `id` fall outside the replay guard: a Tokenizer MUST NOT reject unrecognized members and MUST ignore them when comparing, but MAY compare members defined by an extension it implements, so an extension that scopes a binding can still mean something. Rule 4 now states that authority for one participant to act for another is handler-defined and outside this specification, which the MUST otherwise leaves ungrounded. Markdown tables reflowed to the file convention where new text changed column widths. Rule adjustments in response to review by @igrigorik. * Update docs/specification/payment/tokenization.md Co-authored-by: Ilya Grigorik <ilya@grigorik.com> * docs: use generic binding vocabulary in encrypted credential example The encrypted credential handler embeds binding inside its own encrypted payload rather than calling /tokenize or /detokenize, but the payload is still carrying the shopping-bound checkout_id. Align it with the binding placement guidance in template.md, which now says the binding object is what belongs inside the credential payload. Reported by @amithanda. --------- Co-authored-by: Ilya Grigorik <ilya@grigorik.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A token is bound to a resource and issued to a participant. Those need
different checks — the resource is a replay guard compared for exact
equality, the participant is an authorization question.
#746 moves
identityout of thebindingobject but rule 1 stillcompares it. That can't work, because
identitydepends on who iscalling: a business omits it when detokenizing directly, its PSP
includes it when acting on the business's behalf (platform-tokenizer
L288 and L487). Same token, two legal shapes, so equality over it
passes at most one of them.
typeandidonly, and ignores unrecognizedmembers rather than rejecting or comparing them.
bindingis anopen object now and nothing else defines this.
binding.idresolving,but may check locally if it owns the resource.
/detokenizetold callers to omitidentity"when it was omittedat tokenization"; the prose says omit it when you are the target.
Those disagree — fixed to match the prose.
minLength: 1onbinding.id.