refactor!: make token binding vertical-agnostic - #746
Conversation
igrigorik
left a comment
There was a problem hiding this comment.
There is a validation gotcha in the proposed shape: /tokenize and /detokenize reference only common/types/binding.json, which requires type but does not require a resource identifier. This therefore validates:
{
"type": "dev.ucp.shopping.checkout"
}checkout_binding.json requires checkout_id, but nothing machine-readable connects the discriminator to that schema. The effective handler contract becomes less strict while adding a required field to every client. Doh.
That leads to a design question: do we need a separate type discriminator?
If you squint, the resource identifier is already self-describing:
{ "checkout_id": "checkout_123" } <- existing
{ "booking_id": "booking_123" } <- new
{ "reservation_id": "reservation_123" } <- newIf food re-uses checkout, then it can adopt checkout_id, whereas a token bound to an actual booking would use booking_id...type adds value only if two semantically different resources use the same identifier field, and it seems like we can avoid it.
cc @raginpirate
There was a problem hiding this comment.
Looking at the constraints on both sides:
- Tokenization handlers are horizontal specifications and should not hardcode Shopping's
checkout_id. - The current PR shape creates a validation gap at the OpenAPI layer: because
common/types/binding.jsonrequires onlytype, a request omitting a resource identifier will pass schema validation. - Defining separate binding schemas per vertical (
checkout_binding.json,booking_binding.json, etc.) adds schema maintenance overhead and requires polymorphic model handling in SDKs.
Proposed Alternative: Single binding { type, id }
We can address both requirements with a single common/types/binding.json that requires type and id:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ucp.dev/schemas/common/types/binding.json",
"title": "Binding",
"description": "Binds a credential or token to a specific capability resource and participant identity.",
"type": "object",
"required": ["type", "id"],
"properties": {
"type": {
"$ref": "reverse_domain_name.json",
"description": "The capability type that owns the bound resource (e.g., dev.ucp.shopping.checkout, dev.ucp.dining.reservation)."
},
"id": {
"type": "string",
"description": "The resource identifier within the capability domain (e.g., checkout ID, reservation ID, booking ID)."
},
"identity": {
"$ref": "payment_identity.json",
"description": "Optional participant identity to bind to."
}
},
"additionalProperties": false
}On the Wire:
- Shopping:
{"type": "dev.ucp.shopping.checkout", "id": "chk_12345"} - Lodging:
{"type": "dev.ucp.lodging.booking", "id": "bk_98765"} - Dining:
{"type": "dev.ucp.dining.reservation", "id": "res_54321"}
Trade-offs:
- Validation: OpenAPI validators can strictly enforce that both
typeandidare present on/tokenizeand/detokenize. - Schema Maintenance: Eliminates
checkout_binding.jsonand the need to create new binding schemas for future verticals. - SDK Generation: Produces a single concrete
Bindingmodel across SDK languages rather than an inheritance hierarchy or union type. - Tokenizer Logic: Tokenizers verify exact equality over
type,id, andidentity. - Migration: Requires existing shopping clients to supply
typeand renamecheckout_idtoid. Given that #741 and #746 are already breaking refactors (refactor!:) ahead of the release snapshot, this aligns all verticals on a uniform contract.
@igrigorik , @prasad-stripe What are your thoughts on this approach?
|
Thanks @igrigorik and @gsmith85, both points land. @igrigorik: the validation gap is real. Referencing only the base from @gsmith85: adopting your shape. It closes that gap with a schema constraint instead of a convention, drops Keeping
|
3736145 to
1edae42
Compare
There was a problem hiding this comment.
Thanks for taking a stab at cleaning this up. I am challenging the scope of the primitives use case since we're thinking about extending it elsewhere, but another part of me thinks it might be best to actually delete this object and just inline the rev-dns approach inside of the openrpc example, as I'm not sure how we'll use this across UCP beyond the example files (YAGNI).
I'll also push back strongly on closing the object; no need IMO, extending this object seems valid.
| "id": { | ||
| "type": "string", | ||
| "description": "Opaque identifier of the bound resource within the owning capability, for example a checkout identifier." | ||
| }, |
There was a problem hiding this comment.
Truthfully, a binding associated to a credential may be actually scoped to N unique resources and not just one. The example tokenizer was very specific to checkout and as we look beyond it this shape doesn't extend very well.
I'd lift identity out of this and either make it simply an open rev-dns bag with properties being string id values in the tokenizer openrpc, or make it an array of type + id without identity in it.
There was a problem hiding this comment.
Taking the identity point: agreed. identity moves out to a sibling field on /tokenize and /detokenize. Resource scope and participant scope are different axes, and nesting identity inside a plural binding would be the wrong shape.
On N resources, I would rather not make binding plural here. When a token needs to cover several resources, bind it to the resource that contains them: {"type": "dev.ucp.travel.itinerary", "id": "itn_1"}, or a cart, or an order group. The container is the resource. Plural also forces decisions this PR should not make, such as whether /detokenize presents every binding or a subset, and whether one resource can be released without the others.
On the open reverse-DNS bag: it is plural natively, but it moves the governed value into a property name, so nothing requires an entry and a misspelled capability validates clean. That is the gap @igrigorik flagged. Keeping the capability in a value lets reverse_domain_name.json constrain it.
Worth saying that {type, id} is what makes plural possible later, and additively, since each element would already be a complete binding. {checkout_id} could not. So this shape is a prerequisite for what you are describing rather than an obstacle to it.
Given the container pattern covers the case, I would leave plural until something actually requires it rather than commit to it now.
|
Thanks @raginpirate, two answers.
Keeping
There's also a local consistency cost: the same request body already My read is that binding is single-use because tokenization is currently the only horizontal handler contract, not because the shape is narrow. |
1be2813 to
3ada90e
Compare
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`.
igrigorik
left a comment
There was a problem hiding this comment.
@prasad-stripe @raginpirate the shape y'all converged on makes sense, but I'm tripping up over what reads as contradictory rules. PTAL @ #762 - does this make sense, or am I holding it wrong?
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.
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`.
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.
878abae to
b2a0412
Compare
@igrigorik #762 is merged in. You were right that rule 1 couldn't compare Two tweaks to your rules: Rule 1. Dropped "members other than Rule 4. Added one sentence: delegation is handler-defined and out of scope. Otherwise rule 4 reads as requiring a mechanism UCP doesn't define. Rule 2, the This PR now stands on its own. It's based directly on |
Co-authored-by: Ilya Grigorik <ilya@grigorik.com>
igrigorik
left a comment
There was a problem hiding this comment.
took a few iterations but glad we took the long route!
lgtm 👍
raginpirate
left a comment
There was a problem hiding this comment.
👍 lgtm thanks for the better abstraction on binding!
|
This PR does a great job updating
Could we update these two occurrences to reference the generic |
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.
Thanks @amithanda, good catch. Fixed both in Step 3 now verifies the decrypted |
Description
Addresses the one exception #741 carves out:
binding.jsonstays insource/schemas/shopping/types"as it explicitly references checkout sessions, which is not vertical agnostic."That exception leaves the tokenization handler contract shopping-bound. The contract lives in
source/handlers/, not under a vertical, andbindingis a required field of both/tokenizeand/detokenize. So after a refactor whosestated purpose is vertical-agnostic payments, any non-shopping capability still has to fabricate a
checkout_idto tokenize a credential. The same file already refs the credential fromcommon/, so the asymmetry is only in binding.What changes
bindingbecomes a flat{type, id}pair incommon/types/binding.json.typeis the capability that owns the bound resource and refsreverse_domain_name.json, for exampledev.ucp.shopping.checkout.idis the opaque identifier of that resource within the capability. One shape covers every capability, so there is no per-vertical binding schema and SDKs keep a single concrete model.identitymoves out ofbindingto a sibling field on the/tokenizeand/detokenizerequest bodies:bindingsays which resource the token is for,identitysays which participant it is for.payment_identity.jsonis unchanged in shape; only its position in the request moves. It is still referenced atshopping/types/payment_identity.json, since that is where it lives onmaintoday; refactor!: Refactor Payment constructs (including related extensions) from shopping/ to common/ #741 relocates the file itself.docs/specification/payment/tokenization.md: verification is exact equality overtypeandid, with unrecognized members ignored rather than rejected;binding.idis opaque and need not resolve; an unrecognizedbinding.typeis not grounds for rejection; and every token is issued to exactly one participant, recorded at/tokenizeand verified at/detokenizetogether with the caller's authority to act for it.Why this is cheap for existing tokenizers
Binding was never semantically validated. A tokenizer cannot confirm that a checkout exists, so binding has always been an opaque equality token. This PR states that explicitly, which means an unrecognized
typecosts a tokenizernothing.
The one new risk is cross-type confusion, where a resource identifier from one capability collides with an identifier from another. The equality rule closes it in the same change: verification covers
typeandidtogether, so a matchingidunder a differenttypedoes not verify.Credits
The flat
{type, id}shape was proposed by @gsmith85 in review. Liftingidentityout ofbindingwas raginpirate's push. @igrigorik's #762, merged into this branch, separated the resource replay guard from participant authorization and is what the equality and participant rules now say.Category (Required)
ucp-schematool (resolver, linter, validator). (Requires Maintainer approval)Related Issues
Related to Phase 2 in #520. Includes #762 by @igrigorik.
Not stacked. This branch is based directly on
mainand merges independently of #741. The two PRs overlap in two places, and the overlap makes #741 smaller rather than larger: this PR deletessource/schemas/shopping/types/binding.json, which is the one file #741 carves out as an exception, and adds twopayment_identity$refsinsource/handlers/tokenization/openapi.json, a file #741 already edits. Whichever lands first, the other rebases. Happy to sequence with @jingyli either way.Requesting this rides #741's release rather than a later one. Both are breaking changes to the same schema surface, so landing them together means implementers migrate binding once.
Checklist
!for breaking changes).On the unchecked boxes: this change has no executable test surface, the JSON examples in the touched docs are validated by
scripts/validate_examples.pyin CI, andgenerate_models.shandpython_sdkdo not exist in this repository.Breaking change
Three changes to the same surface:
https://ucp.dev/schemas/shopping/types/binding.jsonmoves tohttps://ucp.dev/schemas/common/types/binding.json.checkout_idis replaced bytypeplusid. A checkout binding becomes{"type": "dev.ucp.shopping.checkout", "id": "<checkout_id>"}.identitymoves out ofbindingto a sibling field on the/tokenizeand/detokenizerequest bodies.Migration for a shopping implementer is one renamed field, one added field, and moving
identityup one level. Because releases are separate snapshots, previously published versions keep their paths.