refactor!: Refactor Payment constructs (including related extensions) from shopping/ to common/ - #741
Conversation
igrigorik
left a comment
There was a problem hiding this comment.
A couple of gotchas to address...
presentation regressions, same flag as #736
Twelve existing payment-field tables collapse to Schema Reference links after their schemas move into common/types. The selected-payment-instrument $defs table also becomes a bare link through extension_schema_fields. Finally, Payment Authentication, Payment Terms, and Split Payments disappear entirely from the generated Extension Schemas reference because the common-root scan excludes extensions and the extension scan only examines Shopping.
The #736 default-inline fix should cover schema_fields; the same default-inline/explicit-opt-out behavior must cover extension_schema_fields:
{{ auto_generate_schema_reference(
'.',
'reference',
include_capability=False,
base_dir='source/schemas/common'
) }}That should retain current rendering and visibility.
Incomplete namespace/path migration
Old identities or locations remain in:
docs/documentation/core-concepts.mddocs/specification/overview.mddocs/specification/payment-authentication.mddocs/specification/payment-handler-guide.mdscripts/scaffolds/common_payment_authentication_response.jsondocs/documentation/schema-authoring.md
The scaffold is particularly concerning: it was renamed to common_payment_authentication_response.json but still advertises dev.ucp.shopping.payment_authentication. Validation accepts this because capability registries are open.
raginpirate
left a comment
There was a problem hiding this comment.
I'd also want to call out that dev.ucp.payment.three_ds_challenge and dev.ucp.payment.device_data_collection also seem to be left under payment instead of common; is the general stance to re-namespace all of these?
| "description": "Extends Checkout with selectable payment terms, letting a Business offer alternative schedules for when payment for the checkout is due, and projects the accepted term onto the resulting Order.", | ||
| "description": "Extends capabilities (e.g., checkout in retail shopping) with selectable payment terms, letting a Business offer alternative schedules for when payment for the checkout is due, and projects the accepted term onto the resulting Order.", | ||
| "requires": { | ||
| "protocol": { "min": "2026-04-08" }, |
There was a problem hiding this comment.
do we need to drop / bump these min protocol requirements on these extensions now that we've re-namespaced them? It doesn't make sense to ref these artifacts against totally old spec versions
There was a problem hiding this comment.
I think dropping them as starters may be better since there is no concrete date we can anchor against. We probably need a follow-up PR to add them back once the new version is cut?
|
Thanks @igrigorik and @raginpirate! presentation regressions, same flag as #736I ran some manual validations after adding in extension rendering for Before:
After:
Incomplete namespace/path migrationI did a pass over the affected files and updated the stale links. One interesting callout is that #723 only consolidated some payment extensions into the @raginpirate's comment:
Per deep-dive discussion, we do not want to accidentally frame
|
raginpirate
left a comment
There was a problem hiding this comment.
As of right now, this PR lands only some payments things as dev.ucp.common.payments and others as just dev.ucp.common.
I'd love to know if we can land this as a unified answer across the board, here's a quick commit my agent threw together... anything off with this direction?
52a6967
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. - `identity` is unchanged. - `additionalProperties: false` makes exact-equality verification decidable. The tokenization handler refs this schema, 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 whole binding object, `id` is opaque and MUST NOT be parsed or resolved, and a tokenizer MUST NOT reject a request solely because it does not recognize `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`, and `checkout_id` is replaced by `type` plus `id`. Checkout bindings become `{"type": "dev.ucp.shopping.checkout", "id": "<checkout_id>"}`. Stacked on #741 so implementers absorb both moves in one release. Binding shape credit to @gsmith85, who proposed the flat form in review of the polymorphic base-plus-subtype version.
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>"}`. Stacked on #741 so implementers absorb both moves in one release. Binding shape credit to @gsmith85, who proposed the flat form in review. Identity separation credit to @raginpirate.
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>"}`. Stacked on #741 so implementers absorb both moves in one release. Binding shape credit to @gsmith85, who proposed the flat form in review. Identity separation credit to @raginpirate.
|
@raginpirate thanks for the callout! Was a miss on my end to misinterpret the Now have updated all payment relevant constructs in this namespace for consistency. PTAL! |
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.
|
@raginpirate Good catch via f62be4f, I've reconciled them into the PR and also merged in #424's scope to this refactoring (added the 2 additional files being moved to |
igrigorik
left a comment
There was a problem hiding this comment.
Two nits, otherwise LGTM.
* 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>
…a standalone capability.
… payment extension specification structure - move them into the payment/ subfolder. Address PR review feedback like stale references, irrelevant rendering refactoring, etc.
…common.payment_terms, etc. on reference.md.
…here no properties are defined.
…o move payment.json into common/types.
…L links in the recent schema refactoring.
…aviour in rebase for mkdocs.yml.
…btree Moving the payment extension docs into payment/extensions/ left four already-published URLs with no file and no redirect entry, so they would have started returning 404 on deploy: /specification/payment/authentication/ /specification/payment/split-payments/ /specification/payment/actions/device-data-collection/ /specification/payment/actions/three-ds-challenge/ The existing redirect entries were rewritten to point at the new targets, which preserved the pre-vertical flat URLs (payment-authentication.md, split-payments.md, payment-actions/*.md) but skipped the specification/payment/ generation introduced by the vertical reorganization. Those are the paths currently served on ucp.dev, so they are the ones a live deep link or a published `spec` URL is most likely to use. The link checker cannot catch this class of break: it validates outbound links, and every internal reference was correctly updated, so nothing points at the dropped paths anymore. URL continuity for already-published addresses is only visible by diffing the redirect map against the deployed URL set.
raginpirate
left a comment
There was a problem hiding this comment.
Thanks for the great work! 🚀


Description
A continuation of #436 , addressing Phase 5 in that PR. Also related to #723 and #736 when it comes to other schema & specification documentation clean-ups.
Design choices made in this PR:
source/schemas/shoppingtosource/schemas/commonas it is a horizontal concept instead of vertical specific:split_payments.jsonpayment_terms.jsonpayment_authentication.jsonap2_mandate.jsonpayment.jsonto a common type instead of living in the same level as the extensions above.binding.jsoninsource/schemas/shopping/typesas it explicitly references checkout sessions, which is not vertical agnostic.dev.ucp.common.paymentnamespace.Category (Required)
Please select one or more categories that apply to this change.
ucp-schematool (resolver, linter, validator). (Requires Maintainer approval)Related Issues
Phase 2 in #520
Checklist
!for breaking changes).Screenshots / Logs (if applicable)
A view of the new payment nav bar that follows the new rendering & extensions nesting:
