From 3c5176483ffa84d131b8bfb85788355be72b2afd Mon Sep 17 00:00:00 2001 From: Gil Greenberg Date: Tue, 1 Sep 2026 16:56:19 -0400 Subject: [PATCH] feat(permalink): add ucp/version protocol version parameter A permalink is a browser GET with no version negotiation, and links outlive their authoring, so the URL must carry the release it was generated against. Add `ucp/version` as a control parameter in the reserved `ucp` root: - Platforms MUST include exactly one `ucp/version`, set to the `ucp.version` of the profile the endpoint was discovered from (dated release only). - Businesses select the release before resolving any field path or compact item token; MUST accept current and `supported_versions` releases and SHOULD keep resolving retired ones; absent -> documented default (SHOULD be oldest supported); unknown well-formed -> fall forward to the oldest later release; never answer with a UCP JSON `version_unsupported` error. - `ucp/version` is consumed on resolution and SHOULD NOT appear on the redirect URL; other `ucp/*` pointers are consumed and never applied. Updates the OpenAPI binding (shared `ucp_version` parameter, control parameter list) and the capability schema description accordingly. --- docs/specification/permalink.md | 146 +++++++++++++++--- source/schemas/shopping/permalink.json | 2 +- .../services/shopping/permalink.openapi.json | 22 ++- 3 files changed, 145 insertions(+), 25 deletions(-) diff --git a/docs/specification/permalink.md b/docs/specification/permalink.md index cce4b9976..773df0460 100644 --- a/docs/specification/permalink.md +++ b/docs/specification/permalink.md @@ -39,6 +39,7 @@ The capability defines: * discovery of the permalink endpoint; * compact item path syntax; +* the `ucp/version` protocol version; * the `continue_to` destination preference; * UCP field-path mapping for initialized shopping state; * redirect resolution semantics. @@ -50,6 +51,16 @@ Business declarations MUST include `config.endpoint`. The endpoint MUST be an absolute HTTPS browser endpoint with a non-empty authority and without userinfo, query, fragment, whitespace, backslashes, or trailing slash. +The declaration is bound to the release of the profile that carries it. A +profile with `ucp.version` `2026-08-25` advertises the permalink capability at +version `2026-08-25` (see +[Component Versioning and Release Snapshots](overview/index.md#component-versioning-and-release-snapshots)), +and the `supported_versions` leaf profile for `2026-04-08` carries its own +declaration at version `2026-04-08`, which MAY use a different endpoint. A +Platform records the profile's `ucp.version` together with the endpoint it +discovers, and every permalink it generates from that endpoint carries that +release in [`ucp/version`](#protocol-version-ucpversion). + Permalinks can also open native apps. A native app can register as a link handler for the endpoint's `https` URLs — Universal Links on iOS, App Links on Android. When the app is installed it intercepts the permalink and handles it @@ -104,15 +115,24 @@ item_pair = item_id_token ":" quantity quantity = positive base-10 integer without leading zeros ``` +The query string carries the `ucp/version` protocol version, the `continue_to` +destination preference, UCP field-path query parameters, and non-UCP query +parameters. A generated permalink always carries `ucp/version` (see +[Protocol Version](#protocol-version-ucpversion)): + +```text +https://merchant.example/buy/sku_123:1?ucp/version={{ ucp_version }} +``` + If no compact items are present, the endpoint MAY still use UCP field-path query parameters when supported by the Business, for example: ```text -https://merchant.example/buy?context/postal_code=94105&continue_to=/ +https://merchant.example/buy?ucp/version={{ ucp_version }}&context/postal_code=94105&continue_to=/ ``` -This URL initializes shopping context with `postal_code` set to `94105` and -requests continuation to `/`. +This URL selects the release, initializes shopping context with `postal_code` +set to `94105`, and requests continuation to `/`. The browser route binding is documented separately at: @@ -222,10 +242,11 @@ a numeric key inside a `gid://…`). ## Query Processing -The query string carries initialized shopping state, destination preferences, -and non-UCP query parameters. Platforms construct UCP field names as JSON -Pointer paths with the leading `/` omitted, such as `buyer/email` or -`line_items/0/quantity`. +The query string carries the protocol version, initialized shopping state, +destination preferences, and non-UCP query parameters. Platforms construct UCP +field names as JSON Pointer paths with the leading `/` omitted, such as +`buyer/email` or `line_items/0/quantity`. The protocol version uses the same +syntax to address the protocol envelope: `ucp/version`. A Business classifies each decoded query parameter name as follows: @@ -235,16 +256,24 @@ A Business classifies each decoded query parameter name as follows: to `/buyer/email`, and `buyer` normalizes to `/buyer`. 3. Parse the normalized pointer into JSON Pointer tokens. The first token is the UCP root candidate. -4. If the root candidate matches a top-level field in a Business-supported UCP +4. If the root candidate is `ucp`, process the parameter as a protocol + parameter. `/ucp/version` is the protocol version; every other `ucp` pointer + is reserved for UCP core and MUST be consumed and removed without being + applied. +5. If the root candidate matches a top-level field in a Business-supported UCP Cart or Checkout schema, including active profiles and extensions, or a field defined by this capability, process the parameter as a UCP field-path query parameter. -5. Otherwise, process the parameter as a non-UCP query parameter. +6. Otherwise, process the parameter as a non-UCP query parameter. + +A Business MUST select the release from `ucp/version` before it resolves any +UCP field-path query parameter or compact item token, since the release +determines the schemas those inputs are interpreted against. Distinct raw keys can normalize to the same pointer (for example `buyer/email` -and `/buyer/email`), and `continue_to` can appear more than once. A Business -MUST detect these collisions after normalization and MUST NOT resolve them by -query-parameter order; it handles them as ambiguous (see +and `/buyer/email`), and `continue_to` or `ucp/version` can appear more than +once. A Business MUST detect these collisions after normalization and MUST NOT +resolve them by query-parameter order; it handles them as ambiguous (see [Merge Rules](#merge-rules) and [Error Handling](#error-handling)). For UCP field-path query parameters, the Business resolves the remaining pointer @@ -259,6 +288,78 @@ field paths on the destination URL only when it explicitly selects and, where appropriate, rewrites them for a defined purpose, such as preserving attribution. A Business MUST NOT forward sensitive values. +### Protocol Version (`ucp/version`) + +`ucp/version` pins the permalink to one UCP release — the same protocol version +that `ucp.version` selects in a profile (see +[Protocol Version](overview/index.md#protocol-version)). It is a protocol +parameter, not shopping-state data, and it addresses the `ucp.version` envelope +member using the same JSON Pointer syntax as UCP field paths: + +```text +/buy/sku_123:1?ucp/version={{ ucp_version }} +``` + +A permalink is a browser navigation with no profile exchange, so there is no +version negotiation when the link is opened: the Business cannot learn which +release the author used from the request itself. A permalink also outlives the +moment it was authored — it is copied, printed, and stored — while the Business +continues to adopt new releases. Without a pin, a breaking change in a later +release could silently reinterpret an existing link. `ucp/version` therefore +fixes the release under which the Business interprets the entire URL: the +compact item path, `continue_to`, and every UCP field-path query parameter, +resolved against the Cart or Checkout schemas and the `dev.ucp.*` capability and +extension versions of that release (see +[Component Versioning and Release Snapshots](overview/index.md#component-versioning-and-release-snapshots)). + +The name and format of `ucp/version` are themselves release-independent so that +a Business can select the release before it interprets the rest of the URL. + +#### Platform Requirements + +A Platform MUST include exactly one `ucp/version` query parameter in every +permalink it generates. The value MUST be the `ucp.version` of the Business +profile from which the Platform discovered the permalink endpoint, in +`YYYY-MM-DD` format; it MUST NOT be a pre-release identifier (see +[Pre-release Versions](overview/index.md#pre-release-versions)). Because the +declaration is bound to its profile, this is also the `version` of the +`dev.ucp.shopping.permalink` entry that supplied `config.endpoint`. + +#### Business Requirements + +A Business MUST accept `ucp/version` values equal to its current `ucp.version` +and to every `supported_versions` key whose leaf profile advertises the +permalink capability, and MUST interpret the request under the selected release. + +Links outlive discovery. When a Business stops advertising a release, it SHOULD +continue to resolve permalinks pinned to that release for as long as its +documented lifecycle policy requires (resolution-only compatibility), even +though it no longer generates or advertises them. Version lifecycle is a +Business policy decision; UCP does not prescribe a sunset schedule. + +When `ucp/version` is absent, the Business MUST resolve the request under a +documented default release. The default SHOULD be the oldest release the +Business still resolves permalinks for: an unpinned link most likely predates +the Business's newer releases, and the oldest interpretation is the least likely +to change its meaning. A Business MUST NOT reject a permalink solely because +`ucp/version` is absent. + +When `ucp/version` is well-formed but names a release the Business does not +resolve — because it never supported it or has retired it — the Business SHOULD +fall forward to the oldest release it still resolves that is later than the +requested one, or to its newest release when none is later, and handle inputs +that do not apply under that release as handled shopping errors (see +[Error Handling](#error-handling)). A Business MUST NOT answer with a UCP JSON +`version_unsupported` error; a permalink response is always a browser redirect +or, when the request cannot be safely interpreted, a `4xx`. + +A value that does not match `^\d{4}-\d{2}-\d{2}$` is malformed. More than one +`ucp/version` parameter, after normalization, is ambiguous. Both are handled +according to [Error Handling](#error-handling). + +The Business consumes `ucp/version`; it carries no meaning at the destination +and SHOULD NOT appear on the redirect URL. + ### Destination Preference (`continue_to`) `continue_to` is a destination preference, not shopping-state data. It requests @@ -472,7 +573,7 @@ the `attribution/` prefix. For example, this permalink request: ```text -/buy/sku_123:1?continue_to=/collections/spring&buyer/email=alice%40example.com&buyer/unknown=foo&attribution/utm_source=email&utm_medium=sms&color=black&access_token=secret +/buy/sku_123:1?ucp/version={{ ucp_version }}&continue_to=/collections/spring&buyer/email=alice%40example.com&buyer/unknown=foo&attribution/utm_source=email&utm_medium=sms&color=black&access_token=secret ``` can resolve to: @@ -484,7 +585,8 @@ Location: https://merchant.example/collections/spring?utm_source=email&utm_mediu In this example: -* consumed: `sku_123:1` initializes server-side shopping state, `continue_to` +* consumed: `ucp/version` selects the release the rest of the URL is interpreted + under, `sku_123:1` initializes server-side shopping state, `continue_to` selects the destination path, and `buyer/email` is applied to server-side state; * dropped: `buyer/unknown` is a UCP field-path query parameter because `buyer` @@ -512,10 +614,10 @@ after applying any safe inputs; otherwise it SHOULD route to a purchase, shopping, or remediation destination appropriate for the failure. Malformed or unsafe requests include malformed item tokens, invalid quantities, -invalid base64url tokens, unsafe `continue_to` values, unparseable queries, or -control characters. A Business SHOULD redirect malformed browser requests to a -safe buyer-facing fallback when possible, but MAY return `4xx` when the request -cannot be safely interpreted. +invalid base64url tokens, malformed `ucp/version` values, unsafe `continue_to` +values, unparseable queries, or control characters. A Business SHOULD redirect +malformed browser requests to a safe buyer-facing fallback when possible, but +MAY return `4xx` when the request cannot be safely interpreted. Error presentation SHOULD prefer server-side session state or destination-page state. @@ -525,7 +627,7 @@ state. ### Single item default purchase ```text -https://merchant.example/buy/sku_123:1 +https://merchant.example/buy/sku_123:1?ucp/version={{ ucp_version }} ``` Initialized data: @@ -552,7 +654,7 @@ Location: https://checkout.merchant.example/session/chk_123 ### Campaign link with discount and continuation ```text -https://merchant.example/buy/sku_123:1,sku_456:2?continue_to=/collections/spring&discounts/codes/0=SPRING10&attribution/utm_source=email +https://merchant.example/buy/sku_123:1,sku_456:2?ucp/version={{ ucp_version }}&continue_to=/collections/spring&discounts/codes/0=SPRING10&attribution/utm_source=email ``` Initialized data: @@ -593,7 +695,7 @@ Location: https://merchant.example/collections/spring?utm_source=email ### Buyer-directed purchase link ```text -https://merchant.example/buy/sku_kit:3,~Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0VmFyaWFudC83MDg4MTQxMg:2?discounts/codes/0=VIP20&discounts/codes/1=WELCOME&buyer/email=alice%40foo.com&buyer/phone_number=123-456-7890&context/address_country=US&context/postal_code=94105&context/language=en-US&attribution/ref=creator_42&attribution/utm_source=social&context/payment/0/handler=com.example.wallet +https://merchant.example/buy/sku_kit:3,~Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0VmFyaWFudC83MDg4MTQxMg:2?ucp/version={{ ucp_version }}&discounts/codes/0=VIP20&discounts/codes/1=WELCOME&buyer/email=alice%40foo.com&buyer/phone_number=123-456-7890&context/address_country=US&context/postal_code=94105&context/language=en-US&attribution/ref=creator_42&attribution/utm_source=social&context/payment/0/handler=com.example.wallet ``` Initialized data: @@ -647,7 +749,7 @@ Location: https://checkout.merchant.example/session/chk_123 ### Pickup with a pre-selected destination ```text -https://merchant.example/buy/sku_123:1?fulfillment/methods/0/type=pickup&fulfillment/methods/0/selected_destination_id=loc_1375 +https://merchant.example/buy/sku_123:1?ucp/version={{ ucp_version }}&fulfillment/methods/0/type=pickup&fulfillment/methods/0/selected_destination_id=loc_1375 ``` Initialized data: diff --git a/source/schemas/shopping/permalink.json b/source/schemas/shopping/permalink.json index c649edb2f..0c83fc0ea 100644 --- a/source/schemas/shopping/permalink.json +++ b/source/schemas/shopping/permalink.json @@ -3,7 +3,7 @@ "$id": "https://ucp.dev/schemas/shopping/permalink.json", "name": "dev.ucp.shopping.permalink", "title": "Permalink Capability", - "description": "Browser-addressable shopping intent capability: defines a Business browser endpoint and redirect resolution. It defines no shopping-state fields of its own; permalink query parameters address existing UCP field paths.", + "description": "Browser-addressable shopping intent capability: defines a Business browser endpoint and redirect resolution. It defines no shopping-state fields of its own; permalink query parameters address existing UCP field paths, interpreted under the release pinned by the `ucp/version` control parameter.", "$defs": { "endpoint": { "type": "string", diff --git a/source/services/shopping/permalink.openapi.json b/source/services/shopping/permalink.openapi.json index cd31c6e6c..06a3d07ac 100644 --- a/source/services/shopping/permalink.openapi.json +++ b/source/services/shopping/permalink.openapi.json @@ -18,12 +18,15 @@ ], "paths": { "/": { - "description": "No-item permalink path. With config.endpoint set to https://merchant.example/buy, this route resolves URLs such as https://merchant.example/buy/?context/postal_code=94105&continue_to=/; the no-trailing-slash constraint applies to the advertised config.endpoint value, not this resolved route URL.", + "description": "No-item permalink path. With config.endpoint set to https://merchant.example/buy, this route resolves URLs such as https://merchant.example/buy/?ucp/version=YYYY-MM-DD&context/postal_code=94105&continue_to=/; the no-trailing-slash constraint applies to the advertised config.endpoint value, not this resolved route URL.", "get": { "operationId": "resolve_permalink", "summary": "Resolve permalink", "description": "Resolves a no-item browser-addressable shopping intent from query parameters and redirects the browser to an appropriate buyer-facing destination.", "parameters": [ + { + "$ref": "#/components/parameters/ucp_version" + }, { "$ref": "#/components/parameters/continue_to" } @@ -47,6 +50,9 @@ { "$ref": "#/components/parameters/items" }, + { + "$ref": "#/components/parameters/ucp_version" + }, { "$ref": "#/components/parameters/continue_to" } @@ -74,6 +80,17 @@ "pattern": "^(?:[A-Za-z0-9._-]+|~[A-Za-z0-9_-]+):[1-9][0-9]*(?:,(?:[A-Za-z0-9._-]+|~[A-Za-z0-9_-]+):[1-9][0-9]*)*$" } }, + "ucp_version": { + "name": "ucp/version", + "in": "query", + "required": false, + "description": "UCP release the permalink is pinned to: the `ucp.version` of the Business profile the endpoint was discovered from. Platforms MUST include it in every generated permalink; Businesses MUST accept its absence and resolve under their documented default release. Normative selection, default, and fall-forward rules are defined in the permalink specification.", + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "description": "Release date in YYYY-MM-DD format. MUST be a dated release, never a pre-release identifier." + } + }, "continue_to": { "name": "continue_to", "in": "query", @@ -122,8 +139,9 @@ } }, "x-ucp-query-semantics": { - "description": "OpenAPI cannot enumerate arbitrary JSON Pointer field-path query parameters such as buyer/email, context/postal_code, context/payment/0/handler, attribution/ref, discounts/codes/0, or profile-specific fields. Permalink query syntax omits the leading '/' for UCP field paths, but Businesses may also accept canonical JSON Pointer names. The Business resolves these field paths against its active schemas, capabilities, and internal logic. The permalink specification defines the normative query partitioning and redirect semantics.", + "description": "OpenAPI cannot enumerate arbitrary JSON Pointer field-path query parameters such as buyer/email, context/postal_code, context/payment/0/handler, attribution/ref, discounts/codes/0, or profile-specific fields. Permalink query syntax omits the leading '/' for UCP field paths, but Businesses may also accept canonical JSON Pointer names. The Business selects the release from ucp/version first, then resolves these field paths against the schemas, capabilities, and internal logic active at that release. The ucp root is reserved for protocol parameters and is never applied as shopping state. The permalink specification defines the normative query partitioning and redirect semantics.", "control_parameters": [ + "ucp/version", "continue_to" ], "field_paths": "json-pointer-with-leading-slash-optional"