From 73285e43912bff9780d1001e2cb893b06278a58b Mon Sep 17 00:00:00 2001 From: Arjun <4277792+arjun2075@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:06:06 -0700 Subject: [PATCH 1/2] feat: add state-bound Offer extension Adds the structural/transport slice of the Offer design from #738: * `dev.ucp.shopping.offer` extends Cart via the standard `$defs`/`allOf` extension-composition pattern, following `discount.json` precedent; * an available Offer carries the Business-computed `proposed_update.line_items` using existing Cart full-replacement semantics, rather than a second mutation vocabulary; * each Offer is bound to the state it was generated against through `applies_to.cart_id` and an opaque, implementation-assigned `state_ref`, so a stale-but-unexpired Offer fails closed; * `id` + `revision` is the operational identity of an immutable Offer artifact; * `impact.total_delta` uses `signed_amount` so a proposal may lower the total; * `apply_offer` applies a stored proposal by reference over both REST and MCP, reusing UCP's existing idempotency mechanism and returning the authoritative Cart. --- source/schemas/shopping/offer.json | 175 +++++++++++++++++++++ source/services/shopping/mcp.openrpc.json | 36 +++++ source/services/shopping/rest.openapi.json | 60 +++++++ 3 files changed, 271 insertions(+) create mode 100644 source/schemas/shopping/offer.json diff --git a/source/schemas/shopping/offer.json b/source/schemas/shopping/offer.json new file mode 100644 index 000000000..1520a8306 --- /dev/null +++ b/source/schemas/shopping/offer.json @@ -0,0 +1,175 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/offer.json", + "name": "dev.ucp.shopping.offer", + "title": "Offer Extension", + "description": "Extends Cart with Business-proposed Cart transitions. An Offer states: given this exact Cart state, the Business proposes this resulting Cart state. Each Offer is bound to the Cart state it was generated against, so a proposal cannot be silently applied to a Cart that has since changed.", + "$defs": { + "applies_to": { + "type": "object", + "description": "The exact Cart state this Offer was generated against.", + "required": [ + "cart_id", + "state_ref" + ], + "properties": { + "cart_id": { + "type": "string", + "description": "Identifier of the Cart this Offer applies to. MUST match the Cart carrying the Offer." + }, + "state_ref": { + "type": "string", + "minLength": 1, + "description": "Opaque, implementation-assigned reference to the Cart state this Offer was generated against. Not interpreted by the Platform. The Business MUST reject application when the Cart no longer matches this state, even if the Offer has not expired." + } + } + }, + "proposed_update": { + "type": "object", + "description": "The Cart state the Business proposes. Uses existing Cart update semantics; this extension defines no separate mutation vocabulary.", + "required": [ + "line_items" + ], + "properties": { + "line_items": { + "type": "array", + "items": { + "$ref": "types/line_item.json" + }, + "description": "Proposed target line items, using the same full-replacement semantics as a Cart update. Represents the complete resulting line-item state, not a delta." + } + } + }, + "impact": { + "type": "object", + "description": "Structured preview of what applying this Offer would change, for agent evaluation. The Cart returned after application remains authoritative.", + "required": [ + "total_delta" + ], + "properties": { + "total_delta": { + "$ref": "../common/types/signed_amount.json", + "description": "Change to the Cart grand total if this Offer is applied, in the Cart currency's ISO 4217 minor units. Positive when the total increases, negative when it decreases." + } + } + }, + "presentation": { + "type": "object", + "description": "Non-authoritative display copy. Not decision-relevant: changing it does not change what the Offer proposes.", + "properties": { + "title": { + "type": "string", + "description": "Short human-readable Offer title (e.g., 'Add a travel charger')." + }, + "description": { + "type": "string", + "description": "Longer human-readable Offer description." + } + } + }, + "available_offer": { + "type": "object", + "description": "An Offer the Business is currently proposing against this Cart state. Applicable only while the source state still matches, it has not expired, and the Business can still honor it. These conditions are conjunctive: a stale but unexpired Offer is not applicable.", + "required": [ + "id", + "revision", + "applies_to", + "expires_at", + "proposed_update", + "impact" + ], + "properties": { + "id": { + "type": "string", + "description": "Business-assigned Offer identifier, stable across revisions." + }, + "revision": { + "type": "string", + "description": "Business-assigned revision of this Offer. Together with `id` this forms the operational identity of an immutable Offer artifact: any change to decision-relevant terms MUST be issued as a new revision." + }, + "applies_to": { + "$ref": "#/$defs/applies_to" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "Offer expiry timestamp (RFC 3339). Expiry invalidates the Offer independently of source-state staleness." + }, + "proposed_update": { + "$ref": "#/$defs/proposed_update" + }, + "impact": { + "$ref": "#/$defs/impact" + }, + "presentation": { + "$ref": "#/$defs/presentation" + } + } + }, + "applied_offer": { + "type": "object", + "description": "Provenance for an Offer that was applied to this Cart, recording which exact Offer revision caused the transition.", + "required": [ + "id", + "revision" + ], + "properties": { + "id": { + "type": "string", + "description": "Identifier of the applied Offer." + }, + "revision": { + "type": "string", + "description": "Revision of the applied Offer." + }, + "affected_line_item_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Identifiers of the Cart line items this Offer created or changed." + } + } + }, + "offers_object": { + "type": "object", + "description": "Offers currently proposed against this Cart, and Offers already applied to it.", + "properties": { + "available": { + "type": "array", + "items": { + "$ref": "#/$defs/available_offer" + }, + "description": "Offers the Business proposes against the current Cart state. Applying one changes the Cart state and makes sibling Offers bound to the previous state stale.", + "ucp_request": "omit" + }, + "applied": { + "type": "array", + "items": { + "$ref": "#/$defs/applied_offer" + }, + "description": "Offers already applied to this Cart, most recent last.", + "ucp_request": "omit" + } + } + }, + "dev.ucp.shopping.cart": { + "title": "Cart with Offers", + "description": "Cart extended with Offer capability.", + "allOf": [ + { + "$ref": "cart.json" + }, + { + "type": "object", + "properties": { + "offers": { + "$ref": "#/$defs/offers_object", + "ucp_request": "omit" + } + } + } + ] + } + } +} diff --git a/source/services/shopping/mcp.openrpc.json b/source/services/shopping/mcp.openrpc.json index a32cebe34..6fa55f126 100644 --- a/source/services/shopping/mcp.openrpc.json +++ b/source/services/shopping/mcp.openrpc.json @@ -284,6 +284,42 @@ "schema": {"$ref": "#/components/schemas/cart_result"} } }, + { + "name": "apply_offer", + "summary": "Apply offer", + "description": "Apply a Business-proposed Offer to a cart by reference. Requires the `dev.ucp.shopping.offer` capability. The Business applies the stored proposal identified by `offer_id` and `revision`; the Platform does not resubmit the proposed `line_items`. The Business MUST reject the request without mutating the cart when the Offer no longer applies to the current cart state, when it has expired, or when `revision` is not the revision the Business would apply. Retrying with the same `idempotency-key` replays the original result rather than applying the Offer a second time. Returns the authoritative cart.", + "params": [ + { + "name": "meta", + "required": true, + "schema": { + "allOf": [ + {"$ref": "#/components/schemas/meta"}, + {"required": ["ucp-agent", "idempotency-key"]} + ] + } + }, + { + "name": "id", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "offer_id", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "revision", + "required": true, + "schema": {"type": "string"} + } + ], + "result": { + "name": "cart", + "schema": {"$ref": "#/components/schemas/cart_result"} + } + }, { "name": "search_catalog", "summary": "Search for products in the catalog", diff --git a/source/services/shopping/rest.openapi.json b/source/services/shopping/rest.openapi.json index 7d6286607..3d29fa694 100644 --- a/source/services/shopping/rest.openapi.json +++ b/source/services/shopping/rest.openapi.json @@ -561,6 +561,48 @@ } } }, + "/carts/{id}/offers/{offer_id}/revisions/{revision}/apply": { + "parameters": [ + { "$ref": "#/components/parameters/cart_id_path" }, + { "$ref": "#/components/parameters/offer_id_path" }, + { "$ref": "#/components/parameters/offer_revision_path" } + ], + "post": { + "operationId": "apply_offer", + "summary": "Apply Offer", + "description": "Apply a Business-proposed Offer to a cart by reference. Requires the `dev.ucp.shopping.offer` capability. The Business applies the stored proposal identified by `offer_id` and `revision`; the Platform MUST NOT resubmit the proposed `line_items`. The Business MUST reject the request without mutating the cart when the Offer no longer applies to the current cart state, when it has expired, or when `revision` is not the revision the Business would apply. Retrying with the same `Idempotency-Key` replays the original result rather than applying the Offer a second time. Returns the authoritative cart.", + "parameters": [ + { "$ref": "#/components/parameters/authorization" }, + { "$ref": "#/components/parameters/x_api_key" }, + { "$ref": "#/components/parameters/signature" }, + { "$ref": "#/components/parameters/signature_input" }, + { "$ref": "#/components/parameters/content_digest" }, + { "$ref": "#/components/parameters/idempotency_key" }, + { "$ref": "#/components/parameters/request_id" }, + { "$ref": "#/components/parameters/user_agent" }, + { "$ref": "#/components/parameters/ucp_agent" }, + { "$ref": "#/components/parameters/content_type" }, + { "$ref": "#/components/parameters/accept" }, + { "$ref": "#/components/parameters/accept_language" }, + { "$ref": "#/components/parameters/accept_encoding" } + ], + "responses": { + "200": { + "description": "Offer applied", + "headers": { + "Signature": { "$ref": "#/components/headers/signature" }, + "Signature-Input": { "$ref": "#/components/headers/signature_input" }, + "Content-Digest": { "$ref": "#/components/headers/content_digest" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/cart_response" } + } + } + } + } + } + }, "/catalog/search": { "post": { "operationId": "search_catalog", @@ -796,6 +838,24 @@ "type": "string" } }, + "offer_id_path": { + "name": "offer_id", + "in": "path", + "required": true, + "description": "The identifier of the Offer to apply.", + "schema": { + "type": "string" + } + }, + "offer_revision_path": { + "name": "revision", + "in": "path", + "required": true, + "description": "The Offer revision the Platform evaluated.", + "schema": { + "type": "string" + } + }, "order_id_path": { "name": "id", "in": "path", From 015d4c8b639e6869044d174e462960e41f08ceb9 Mon Sep 17 00:00:00 2001 From: Arjun <4277792+arjun2075@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:36:21 -0700 Subject: [PATCH 2/2] docs: tighten Offer state and retry semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incorporates the actionable review feedback from #738 without expanding the surface area of this PR. Description-only changes; no schema shape, endpoint, or parameter changes. * `state_ref` now specifies exact opaque comparison semantics: the Business compares using exact token-value equality, and implementations MUST NOT parse, normalize, canonicalize, or semantically compare the value. JSON serialization/escaping is deliberately kept out of the state identity contract. * The three Offer invalidation reasons are machine-distinct via `messages[].code` — `offer_source_state_mismatch`, `offer_expired`, and `offer_unavailable` — and MUST NOT be collapsed into a single generic invalid-offer outcome, so an agent can pick the right recovery (re-evaluate the Cart, discard the Offer, or refresh alternatives). These use ordinary UCP business-outcome messages rather than new transport-level error machinery. * Idempotent replay is distinguished from a new application attempt: a replay returns the cached original result without re-executing the Offer or re-evaluating `state_ref`, while any new logical attempt MUST evaluate the Offer against the Cart's current state. This preserves normal UCP idempotency semantics instead of making a retry fail stale. REST and MCP describe identical semantics, differing only in existing transport terminology. --- source/schemas/shopping/offer.json | 2 +- source/services/shopping/mcp.openrpc.json | 2 +- source/services/shopping/rest.openapi.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/source/schemas/shopping/offer.json b/source/schemas/shopping/offer.json index 1520a8306..984750afb 100644 --- a/source/schemas/shopping/offer.json +++ b/source/schemas/shopping/offer.json @@ -20,7 +20,7 @@ "state_ref": { "type": "string", "minLength": 1, - "description": "Opaque, implementation-assigned reference to the Cart state this Offer was generated against. Not interpreted by the Platform. The Business MUST reject application when the Cart no longer matches this state, even if the Offer has not expired." + "description": "Opaque, implementation-assigned reference to the exact Cart state this Offer was generated against. Not interpreted by the Platform. The Business MUST determine whether the source state still matches using exact token-value equality between this value and its current state reference for the Cart. Implementations MUST NOT parse, normalize, canonicalize, or semantically compare state_ref values. The Business MUST reject a new application attempt when the Cart no longer matches this state, even if the Offer has not expired." } } }, diff --git a/source/services/shopping/mcp.openrpc.json b/source/services/shopping/mcp.openrpc.json index 6fa55f126..fb6de8a9d 100644 --- a/source/services/shopping/mcp.openrpc.json +++ b/source/services/shopping/mcp.openrpc.json @@ -287,7 +287,7 @@ { "name": "apply_offer", "summary": "Apply offer", - "description": "Apply a Business-proposed Offer to a cart by reference. Requires the `dev.ucp.shopping.offer` capability. The Business applies the stored proposal identified by `offer_id` and `revision`; the Platform does not resubmit the proposed `line_items`. The Business MUST reject the request without mutating the cart when the Offer no longer applies to the current cart state, when it has expired, or when `revision` is not the revision the Business would apply. Retrying with the same `idempotency-key` replays the original result rather than applying the Offer a second time. Returns the authoritative cart.", + "description": "Apply a Business-proposed Offer to a cart by reference. Requires the `dev.ucp.shopping.offer` capability. The Business applies the stored proposal identified by `offer_id` and `revision`; the Platform does not resubmit the proposed `line_items`. For a new logical application attempt, the Business MUST reject the request without mutating the cart when the Offer's bound source state no longer exactly matches the current cart state, when the Offer has expired, or when the Business can no longer honor the exact stored revision. These business outcomes MUST remain machine-distinct using `messages[].code`: `offer_source_state_mismatch`, `offer_expired`, and `offer_unavailable`, respectively, and MUST NOT be collapsed into a single generic invalid-offer outcome. An idempotent replay of the same application request returns the cached original result without re-executing the Offer or re-evaluating `state_ref`. Any new logical application attempt MUST evaluate the Offer against the cart's current state. Returns the authoritative cart.", "params": [ { "name": "meta", diff --git a/source/services/shopping/rest.openapi.json b/source/services/shopping/rest.openapi.json index 3d29fa694..81d282aa9 100644 --- a/source/services/shopping/rest.openapi.json +++ b/source/services/shopping/rest.openapi.json @@ -570,7 +570,7 @@ "post": { "operationId": "apply_offer", "summary": "Apply Offer", - "description": "Apply a Business-proposed Offer to a cart by reference. Requires the `dev.ucp.shopping.offer` capability. The Business applies the stored proposal identified by `offer_id` and `revision`; the Platform MUST NOT resubmit the proposed `line_items`. The Business MUST reject the request without mutating the cart when the Offer no longer applies to the current cart state, when it has expired, or when `revision` is not the revision the Business would apply. Retrying with the same `Idempotency-Key` replays the original result rather than applying the Offer a second time. Returns the authoritative cart.", + "description": "Apply a Business-proposed Offer to a cart by reference. Requires the `dev.ucp.shopping.offer` capability. The Business applies the stored proposal identified by `offer_id` and `revision`; the Platform MUST NOT resubmit the proposed `line_items`. For a new logical application attempt, the Business MUST reject the request without mutating the cart when the Offer's bound source state no longer exactly matches the current cart state, when the Offer has expired, or when the Business can no longer honor the exact stored revision. These business outcomes MUST remain machine-distinct using `messages[].code`: `offer_source_state_mismatch`, `offer_expired`, and `offer_unavailable`, respectively, and MUST NOT be collapsed into a single generic invalid-offer outcome. An idempotent replay of the same application request returns the cached original result without re-executing the Offer or re-evaluating `state_ref`. Any new logical application attempt MUST evaluate the Offer against the cart's current state. Returns the authoritative cart.", "parameters": [ { "$ref": "#/components/parameters/authorization" }, { "$ref": "#/components/parameters/x_api_key" },