From 67051f397422139fc4bc255eae150d6ad26cb9d3 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Thu, 30 Jul 2026 09:31:22 -0700 Subject: [PATCH 1/5] feat!: define sale-basis steps for quantities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UCP's integer `quantity` had no denominator. It could represent two bottles, but not 1.50 kg of loose goods, while preserving exact fulfillment, adjustment, and return arithmetic. The commerce model distinguishes three cases: - Each or fixed package: `quantity` counts purchasable variants. A bottle or a 50 m cable spool sold as one package uses `quantity: 1` and omits `quantity_unit`. Its fixed contents are part of the variant identity; `unit_price` can still display a comparison such as price per metre. - Variable amount: the measured amount itself participates in fulfillment and returns. `quantity_unit: { unit: "KGM", scale: 2 }` makes each integer quantity step 0.01 kg, so `quantity: 150` means 1.50 kg. - Units plus variable configuration: three made-to-measure curtains at 58 cm each have two independent numbers. `quantity: 3` counts the curtains; 58 cm configures what each curtain is. Configured measurements are intentionally deferred rather than conflated with lifecycle quantity. Model the first two cases with a shared unit descriptor containing a machine `unit`, required `display_text`, and optional nonnegative `scale`. One step is `10^-scale` of the unit, and machine identity is the (`unit`, effective `scale`) pair. `display_text` is presentation data and does not participate in identity matching. Preserve `each` as the default sale basis. Omitting `quantity_unit` from authoritative data means (`C62`, 0), and `C62` cannot use a nonzero scale. This keeps existing countable-goods payloads unchanged. Prefer UN/CEFACT Recommendation 20 Common Codes without embedding a UCP unit ontology. When no code accurately identifies a unit, a Business may use a custom identifier but must use it consistently. Platforms treat unknown identifiers as opaque and render the supplied `display_text`. Package codes remain outside `quantity_unit`; package form belongs to variant identity and packages count as `each`. Define capability behavior around the shared representation: - Catalog advertises a variant's sale basis. - Cart and Checkout interpret request omission as no unit assertion. Platforms may assert a (`unit`, effective `scale`) identity, and Businesses reject mismatches as recoverable outcomes rather than silently converting quantities. - Business responses echo `quantity_unit` for every non-`each` line. - Order, fulfillment, and adjustment quantities inherit the line's sale basis, preserving exact integer status and return arithmetic. Quote `price` per one whole `quantity_unit.unit`, not per integer step, because per-step prices can require fractional currency minor units. Businesses compute `price × quantity × 10^-scale`, round once at the line, and return authoritative totals. For example, fasteners priced at 1299 USD per kg with `scale: 2` and `quantity: 150` represent 1.50 kg. The line total is 1948.5 minor units, rounded once to 1949. A fulfillment quantity of 50 represents 0.50 kg, and an adjustment of -25 represents a 0.25 kg return. Keep `unit_price` separate as a display comparator and harmonize its `measure` and `reference` with the shared descriptor. Their values must be positive integers, their units must match, and `unit_price.currency` must equal `price.currency`; no unit or currency conversion occurs in the comparator. This is backward compatible for payloads that do not use `unit_price`: `quantity` remains an integer, `quantity_unit` is optional, and omission retains the existing `each` wire representation. This is breaking for existing `unit_price` producers. `measure` and `reference` must migrate from decimal/free-form measurements to integer values with required `unit` and `display_text` descriptors. Zero and negative comparator measures are now invalid, and same-unit and same-currency equality are normative invariants. --- .cspell/custom-words.txt | 2 + docs/specification/cart.md | 21 ++++ docs/specification/catalog/index.md | 99 +++++++++++++++++ docs/specification/checkout.md | 100 ++++++++++++++++++ docs/specification/glossary.md | 2 + docs/specification/order.md | 88 ++++++++++++++- docs/specification/overview.md | 68 ++++++++++++ source/schemas/common/types/measure.json | 21 ++++ source/schemas/common/types/unit.json | 39 +++++++ source/schemas/shopping/types/adjustment.json | 2 +- .../schemas/shopping/types/expectation.json | 2 +- .../shopping/types/fulfillment_event.json | 2 +- source/schemas/shopping/types/item.json | 7 +- source/schemas/shopping/types/line_item.json | 2 +- .../shopping/types/order_line_item.json | 10 +- source/schemas/shopping/types/variant.json | 52 ++++++--- 16 files changed, 487 insertions(+), 30 deletions(-) create mode 100644 source/schemas/common/types/measure.json create mode 100644 source/schemas/common/types/unit.json diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index edf126445..d028a6899 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -9,6 +9,7 @@ Anytown Backordered Braintree CFRG +CEFACT CIMD Carrefour Centricity @@ -99,6 +100,7 @@ llmstxt mastercard meunier midsole +millilitre misparsed mkdocs mtok diff --git a/docs/specification/cart.md b/docs/specification/cart.md index 13adb23d8..b68a20b43 100644 --- a/docs/specification/cart.md +++ b/docs/specification/cart.md @@ -89,6 +89,27 @@ SHOULD be linked for the duration of the checkout. on a cleared cart ID return `not_found`; the platform can start a new session with `create_cart`. +## Quantity and sale basis + +Cart line items apply the shared +[quantities and units](overview.md#quantities-and-units) contract. Each +`line_items[].quantity` is an integer step count in the item's authoritative +sale basis. On a Business response, an absent +`line_items[].item.quantity_unit` encodes the default `each` basis, so +`quantity` counts whole items. + +On a Platform request, omission of `line_items[].item.quantity_unit` makes no +assertion. The Business interprets `quantity` using the item's authoritative +sale basis, so a request for a measure-denominated item can omit the descriptor +without asserting `each`. The Platform **MAY** include `item.quantity_unit` to +assert the sale-basis identity. + +Cart follows [Checkout — Quantity and sale basis](checkout.md#quantity-and-sale-basis) +for assertion matching, mismatch handling, response echo, and line pricing. +Cart totals remain estimates (see [Total](#total)). A measure-denominated line +counts as one line item in cart summaries; its `quantity` is an amount, not an +item count. + ## Actions The cart surfaces outstanding Action instances in its response-only `actions` diff --git a/docs/specification/catalog/index.md b/docs/specification/catalog/index.md index 2964757ac..75ae08bfd 100644 --- a/docs/specification/catalog/index.md +++ b/docs/specification/catalog/index.md @@ -41,6 +41,9 @@ This enables product discovery before checkout, supporting use cases like: Large"), price, and availability. * **Price**: Price values include both amount (in minor currency units) and currency code, enabling multi-currency catalogs. +* **Sale basis**: How quantity is denominated—as whole items (`each`, the + default) or in a unit of measure such as weight, length, area, volume, or + time. See [Quantities and units](../overview.md#quantities-and-units). ### Relationship to Checkout @@ -53,6 +56,102 @@ terms for the given request but are not transactional commitments — checkout is authoritative. Responses can be session-specific and **SHOULD NOT** be reused across sessions without re-validation. +## Sale basis and quantity units + +`variants[].quantity_unit` advertises a variant's sale basis before a +transaction. The descriptor follows the shared +[quantities and units](../overview.md#quantities-and-units) contract. Its +absence advertises the default `each` basis; the Business advertises a +non-`each` basis by including the descriptor. + +The variant identity, such as a stock keeping unit (SKU), defines *what* each +step is a step of; `quantity_unit` defines *how much* one step is. Stainless +steel fasteners whose `quantity_unit` is +`{ "unit": "KGM", "scale": 2, "display_text": "kg" }` are sold in +hundredth-of-a-kilogram steps, so a `quantity` of `150` represents 1.50 kg. + +### Distinction from unit price + +`quantity_unit` and a variant's [`unit_price`](#variant) answer different +questions and are set independently: + +* `quantity_unit` is the **sale basis** — the unit `quantity` is counted in and + `price` is quoted in. +* `unit_price` is a **display comparator** — a derived "price per standard + measure" (for example, per 100 mL) for shelf-style comparison. Its `measure` + is the packaging or content quantity of the variant and its `reference` is the + comparison denominator. + +The unit fields in `quantity_unit`, `unit_price.measure`, and +`unit_price.reference` follow the shared +[quantities and units](../overview.md#quantities-and-units) contract. The two +unit-price fields use the shared measure type, so their integer `value` fields +are step counts. The Business **MAY** provide `quantity_unit`, `unit_price`, +both, or neither on a variant. + +To keep the display comparator defined, the Business **MUST** use a positive +integer for both `unit_price.measure.value` and `unit_price.reference.value`. +The Business **MUST** set `unit_price.currency` equal to `price.currency`. +Within `unit_price`, the Business **MUST** use identical values for +`measure.unit` and `reference.unit`. The Business **MAY** use different `scale` +values for those measures. The Business **MUST NOT** perform cross-unit or +currency conversion as part of the unit-price calculation. Each measure +represents its integer `value × 10^-scale` in the common unit. The Business +**MUST** compute the comparator from `price.amount` and those scaled values: + +```text +(price.amount / (measure.value × 10^-measure.scale)) × (reference.value × 10^-reference.scale) +``` + +The Business **MUST** round the result once to the currency's minor units +according to its pricing rules and return it as `unit_price.amount`. The +returned `unit_price.amount` is authoritative. The Platform **MUST NOT** +recompute it or substitute its own result. + +The same-unit and same-currency rules are semantic invariants. JSON Schema +validates the corresponding fields independently and does not enforce either +equality. + +For example, a 50 m cable spool sold by `each` can omit `quantity_unit` while +carrying a `unit_price` per metre. Its `measure` can be +`{ "value": 5000, "unit": "MTR", "scale": 2, "display_text": "m" }` and its +`reference` can be +`{ "value": 1, "unit": "MTR", "display_text": "m" }`. Both measures use +`MTR`; they represent 50 m and 1 m respectively without cross-unit conversion. + +### Example: a good sold by weight + +A `get_product` response for loose metal fasteners sold by the kilogram. The +variant advertises `quantity_unit` +`{ "unit": "KGM", "scale": 2, "display_text": "kg" }` and a `price` of `1299` — +$12.99 per whole kilogram: + + +```json +{ + "ucp": { "version": "{{ ucp_version }}" }, + "product": { + "id": "prod_fasteners", + "title": "Stainless Steel Fasteners", + "description": { "plain": "Fasteners sold by the kilogram." }, + "price_range": { + "min": { "amount": 1299, "currency": "USD" }, + "max": { "amount": 1299, "currency": "USD" } + }, + "variants": [ + { + "id": "var_fasteners", + "title": "Stainless Steel Fasteners", + "description": { "plain": "Fasteners sold by the kilogram." }, + "price": { "amount": 1299, "currency": "USD" }, + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg" }, + "availability": { "available": true } + } + ] + } +} +``` + ## Shared Entities ### Context diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index d2b4bd68e..94d9b8a6e 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -51,6 +51,106 @@ Fulfillment is optional in the checkout object. This is done to enable a platform to perform checkout for digital goods without needing to furnish fulfillment details more relevant for physical goods. +### Quantity and sale basis + +Checkout applies the shared +[quantities and units](overview.md#quantities-and-units) contract, including the +default (`C62`, `0`) identity. The Business determines each item's authoritative +sale basis for the transaction. The Business's response is authoritative, and +each `line_items[].quantity` is an integer step count in that basis. + +**Requesting a quantity.** When a Platform request omits +`line_items[].item.quantity_unit`, the Platform makes no assertion about the +sale-basis identity. The Business interprets `quantity` using the item's +authoritative sale basis. A request for a measure-denominated item can therefore +omit the descriptor without asserting the default identity. + +The Platform **MAY** include `quantity_unit` on a request line to assert the +basis it believes it is ordering in. When the Platform includes it, the Business +**MUST** compare the asserted and authoritative +[machine identities](overview.md#quantities-and-units). An explicit assertion +with `unit: "C62"` and effective `scale` `0` matches the default identity +represented by an absent descriptor; a `display_text` difference is not a +mismatch. + +If an asserted `quantity_unit` does not match the item's authoritative sale +basis, the Business **MUST** reject that line with a recoverable business outcome +that names the authoritative unit — a `messages[]` entry on a `200` response, +not a transport error (see [Error Handling](#error-handling)). The Business and +Platform **MUST NOT** silently convert a quantity between units in either +direction. A quantity the receiver cannot read in the authoritative basis is an +error to surface, not a value to reinterpret. + +**Echoing the sale basis.** The Business **MAY** omit `quantity_unit` from a +response line whose effective sale-basis identity is the default identity. The +Business **MUST** include `quantity_unit` on the item in every Cart, Checkout, +and Order line response whenever the effective identity differs from the +default identity. Omission would otherwise make a measure-denominated line read +as a count of whole items. + +**Pricing a line.** `item.price` is the amount per one whole +`quantity_unit.unit` (for example, per kg or per hour); when `quantity_unit` is +absent, it is the price per `each`. The Business **MUST** compute the line total +as `price × quantity × 10^-scale` and round once at the line. The presented +`totals[]` remain authoritative (see [Totals](checkout.md#totals)). The Platform +**MUST NOT** recompute a line total from the fractional quantity and substitute +its own rounding. + +For example, a Business sells loose stainless steel fasteners by the kilogram +with `quantity_unit` +`{ "unit": "KGM", "scale": 2, "display_text": "kg" }` and a `price` of `1299` +($12.99/kg). A Buyer orders 1.50 kg, so the Platform sends `quantity` `150` — 150 +hundredth-of-a-kilogram steps. The line total is +`1299 × 150 × 10^-2 = 1948.5`, rounded once to `1949` ($19.49): + + +```json +{ + "ucp": { "version": "{{ ucp_version }}", "status": "success", "payment_handlers": {} }, + "id": "chk_fasteners_1", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li_fasteners", + "item": { + "id": "var_fasteners", + "title": "Stainless Steel Fasteners", + "price": 1299, + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg" } + }, + "quantity": 150, + "totals": [ + { "type": "subtotal", "amount": 1949 }, + { "type": "total", "amount": 1949 } + ] + } + ], + "totals": [ + { "type": "subtotal", "amount": 1949 }, + { "type": "total", "amount": 1949 } + ], + "links": [] +} +``` + +If the Platform instead asserts a unit the Business does not sell the item in, +the Business returns the current Checkout with a recoverable message that names +the authoritative unit and leaves the quantity for the Platform to resubmit: + + +```json +[ + { + "type": "error", + "code": "quantity_unit_mismatch", + "severity": "recoverable", + "path": "$.line_items[0].item.quantity_unit", + "content": "This item is sold by the kilogram (KGM). Resubmit the quantity in 0.01-kg steps." + } +] +``` + ### Checkout Status Lifecycle The checkout `status` field indicates the current phase of the session and diff --git a/docs/specification/glossary.md b/docs/specification/glossary.md index 716e41f7f..ac9dd4f21 100644 --- a/docs/specification/glossary.md +++ b/docs/specification/glossary.md @@ -46,6 +46,8 @@ acronym in each specification Markdown file spells out the full term (e.g., | **Merchant of Record** | MoR | The legal entity responsible for the sale, including financial liability and order ownership. | | **Payment Service Provider** | PSP | The financial infrastructure provider that processes payments, authorizations, and settlements on behalf of the business. | | **Platform** | - | The consumer-facing surface (AI agent, app, website) acting on behalf of the user to discover businesses and facilitate commerce. | +| **Quantity Unit** | - | A [sale-basis descriptor](overview.md#quantities-and-units) with machine identity (`unit`, effective `scale`); `display_text` is excluded. | +| **Step** | - | One [integer increment](overview.md#quantities-and-units) in the authoritative sale basis: `10^-scale` of `unit`. | ## Payments diff --git a/docs/specification/order.md b/docs/specification/order.md index ac5650ee4..e0cf11d8f 100644 --- a/docs/specification/order.md +++ b/docs/specification/order.md @@ -56,7 +56,11 @@ Orders have three main components: Line items reflect what was purchased at checkout and their current state: * Item details (product, price, quantity ordered) -* Quantity counts and fulfillment status +* Quantity counts and fulfillment status — `original`, `total`, and `fulfilled` + are integer step counts of the item's inherited `quantity_unit` under the + shared [quantities and units](overview.md#quantities-and-units) contract; an + absent `quantity_unit` means the quantities count whole items (`each`) at + `scale` 0 ### Fulfillment @@ -109,8 +113,8 @@ fulfillment: `price_adjustment`, `dispute`, `cancellation`) * Can be any post-order change * Optionally link to line items (or order-level for things like shipping refunds) -* Quantities and amounts are signed—negative for reductions (returns, refunds), - positive for additions (exchanges) +* Quantities are signed step counts and amounts are signed—negative for + reductions (returns, refunds), positive for additions (exchanges) * Include totals breakdown when relevant * Can happen at any time regardless of fulfillment status @@ -137,8 +141,16 @@ Line items reflect what was purchased at checkout and their current state. } ``` +When the item is measure-denominated these are step counts — for an +`item.quantity_unit` of +`{ "unit": "KGM", "scale": 2, "display_text": "kg" }`, `fulfilled: 50` means +0.50 kg fulfilled. + **Status Derivation:** +`total` and `fulfilled` are step counts in the same inherited +`item.quantity_unit`; the derivation operates on those counts: + ```text if (total == 0) → "removed" else if (fulfilled == total) → "fulfilled" @@ -278,6 +290,76 @@ Examples: `refund`, `return`, `credit`, `price_adjustment`, `dispute`, } ``` +## Example: goods sold by measure + +An order for loose stainless steel fasteners sold by the kilogram +(`item.quantity_unit` +`{ "unit": "KGM", "scale": 2, "display_text": "kg" }`). Quantities are step +counts: the Buyer ordered 1.50 kg (`total: 150`), of which 0.50 kg has shipped +(`fulfilled: 50`), so the line is `partial`. A later return of 0.25 kg is +recorded as an adjustment of `-25` steps. Amounts are priced at $12.99/kg and +rounded once — the line total is `1299 × 1.50 = 1949`, and the return credits +`1299 × 0.25 = 324.75`, rounded to `325`: + + +```json +{ + "ucp": { + "version": "{{ ucp_version }}", + "capabilities": { "dev.ucp.shopping.order": [{"version": "{{ ucp_version }}"}] } + }, + "id": "order_fasteners_1", + "checkout_id": "chk_fasteners_1", + "permalink_url": "https://business.example.com/orders/fasteners1", + "currency": "USD", + "line_items": [ + { + "id": "li_fasteners", + "item": { + "id": "var_fasteners", + "title": "Stainless Steel Fasteners", + "price": 1299, + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg" } + }, + "quantity": { "original": 150, "total": 150, "fulfilled": 50 }, + "totals": [ + { "type": "subtotal", "amount": 1949 }, + { "type": "total", "amount": 1949 } + ], + "status": "partial" + } + ], + "fulfillment": { + "events": [ + { + "id": "evt_1", + "occurred_at": "2026-01-08T10:30:00Z", + "type": "shipped", + "line_items": [{ "id": "li_fasteners", "quantity": 50 }], + "tracking_number": "123456789", + "tracking_url": "https://carrier.example/track/123456789", + "description": "0.50 kg shipped" + } + ] + }, + "adjustments": [ + { + "id": "adj_1", + "type": "return", + "occurred_at": "2026-01-10T14:30:00Z", + "status": "completed", + "line_items": [{ "id": "li_fasteners", "quantity": -25 }], + "totals": [{ "type": "total", "amount": -325 }], + "description": "Returned 0.25 kg" + } + ], + "totals": [ + { "type": "subtotal", "amount": 1949 }, + { "type": "total", "amount": 1949 } + ] +} +``` + ## Scopes The Order capability defines the following well-known scopes for diff --git a/docs/specification/overview.md b/docs/specification/overview.md index 0793cea17..a6ff5207e 100644 --- a/docs/specification/overview.md +++ b/docs/specification/overview.md @@ -31,6 +31,74 @@ Schema notes: unless otherwise specified - Amounts format: Minor units (cents) +## Quantities and units + +UCP uses a shared quantity representation wherever a schema contains an integer +`quantity`, a `quantity_unit`, or the shared measure type. + +A `quantity` is an integer count of **steps**. A unit descriptor consists of: + +- `unit` — a required, stable machine identifier. +- `scale` — an optional nonnegative integer. Its effective value is the provided + value or `0` when omitted. +- `display_text` — a required printable label for the unit. + +One step is `10^-scale` of `unit`. The shared measure type adds a required +integer `value`, which is also a count of those steps. Because these counts are +integers, `scale` fixes the representation's granularity. A unit descriptor's +machine identity is the (`unit`, effective `scale`) pair; `display_text` is not +part of that identity. + +The default sale basis is `each`, with machine identity (`C62`, `0`). `C62` is +the United Nations Centre for Trade Facilitation and Electronic Business +(UN/CEFACT) Recommendation 20 (Rec20) Common Code for one/each. The Business +**MAY** omit `quantity_unit` from an authoritative Business representation to +encode this default. When a Business or Platform includes a +descriptor whose `unit` is `C62`, it **MUST** use an effective `scale` of `0`; +`scale` can only be omitted or explicitly set to `0`. + +### Unit vocabulary + +The Business **SHOULD** use the exact Rec20 Common Code unless no code +accurately identifies the unit. When no Rec20 code accurately identifies the +unit, the Business **MAY** use a custom unit identifier. If it does, the Business +**MUST** use that identifier consistently for the same unit. The Platform +**MUST** treat an unrecognized `unit` value as opaque. The following table is +non-exhaustive: + +| Code | Unit | +| :---- | :----------- | +| `C62` | one / `each` | +| `KGM` | kilogram | +| `GRM` | gram | +| `LBR` | pound | +| `MLT` | millilitre | +| `LTR` | litre | +| `MTR` | metre | +| `INH` | inch | +| `YRD` | yard | +| `FTK` | square foot | +| `MTK` | square metre | +| `HUR` | hour | +| `MIN` | minute | + +Rec20 includes X-prefixed package units derived from UN/CEFACT Recommendation +21 (Rec21). UCP deliberately excludes those values from `quantity_unit`. The +Business **MUST** make package form part of the purchasable variant's identity +and count packages as `each`. The Business **MUST NOT** use an X-prefixed +Rec21-derived package code as `quantity_unit`. + +### Display text + +When sending a unit descriptor, a Business or Platform **MUST** include +`display_text`. The Platform **MUST** use that value when it does not recognize +`unit`. For a recognized Rec20 code, the Platform **MAY** substitute its own +localized label. The Business and Platform **MUST NOT** use `display_text` when +matching machine identities or as an input to quantity conversion. + +Request assertions, mismatch handling, response echo, pricing, and lifecycle +behavior are defined by the capability that uses the shared representation. + ## Actions An Action is an outstanding unit of extension-defined work for a Platform to diff --git a/source/schemas/common/types/measure.json b/source/schemas/common/types/measure.json new file mode 100644 index 000000000..ff305403d --- /dev/null +++ b/source/schemas/common/types/measure.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/measure.json", + "title": "Measure", + "description": "A measure composed of an integer value and a unit descriptor. Its value is the integer count of `10^-scale` units of `unit`.", + "allOf": [ + { + "$ref": "unit.json" + }, + { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "integer", + "description": "Integer count of `10^-scale` units of `unit`." + } + } + } + ] +} diff --git a/source/schemas/common/types/unit.json b/source/schemas/common/types/unit.json new file mode 100644 index 000000000..5117af19d --- /dev/null +++ b/source/schemas/common/types/unit.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/unit.json", + "title": "Unit", + "description": "A reusable unit descriptor for quantities and measures. Its machine identity is (`unit`, effective `scale`), where effective `scale` is the provided `scale` or 0; `display_text` is excluded.", + "type": "object", + "required": ["unit", "display_text"], + "properties": { + "unit": { + "type": "string", + "description": "Stable machine identifier. The Business SHOULD use the exact UN/CEFACT Rec20 Common Code when one accurately identifies the unit. Otherwise, the Business MAY use a custom unit identifier and MUST use it consistently for the same unit. The Platform MUST treat an unrecognized identifier as opaque." + }, + "scale": { + "type": "integer", + "minimum": 0, + "default": 0, + "description": "One step equals `10^-scale` of `unit`. When `unit` is `C62`, `scale`, if present, MUST be 0." + }, + "display_text": { + "type": "string", + "description": "Required printable unit label provided by the Business. The Platform MUST use it when it does not recognize `unit`; for a recognized UN/CEFACT Rec 20 Common Code, the Platform MAY substitute its own localized label. It does not participate in unit identity or mismatch comparison." + } + }, + "allOf": [ + { + "if": { + "properties": { + "unit": { "const": "C62" } + }, + "required": ["unit"] + }, + "then": { + "properties": { + "scale": { "const": 0 } + } + } + } + ] +} diff --git a/source/schemas/shopping/types/adjustment.json b/source/schemas/shopping/types/adjustment.json index c4fa91fbd..5f3fe039d 100644 --- a/source/schemas/shopping/types/adjustment.json +++ b/source/schemas/shopping/types/adjustment.json @@ -45,7 +45,7 @@ }, "quantity": { "type": "integer", - "description": "Signed quantity affected by this adjustment. Negative values represent reductions (e.g. returns); positive values represent additions (e.g. exchanges)." + "description": "Signed integer count of steps of the referenced line item's `quantity_unit` (`10^-scale` × `unit`); when `quantity_unit` is absent, it counts whole items (`each`). Negative values represent reductions (e.g. returns); positive values represent additions (e.g. exchanges)." } } }, diff --git a/source/schemas/shopping/types/expectation.json b/source/schemas/shopping/types/expectation.json index ccd01c8e9..8c7993eaf 100644 --- a/source/schemas/shopping/types/expectation.json +++ b/source/schemas/shopping/types/expectation.json @@ -28,7 +28,7 @@ "quantity": { "type": "integer", "minimum": 1, - "description": "Quantity of this item in this expectation." + "description": "Integer count of steps of the referenced line item's `quantity_unit` (`10^-scale` × `unit`); when `quantity_unit` is absent, it counts whole items (`each`)." } } }, diff --git a/source/schemas/shopping/types/fulfillment_event.json b/source/schemas/shopping/types/fulfillment_event.json index 5d0ba7e7f..84ddb6e18 100644 --- a/source/schemas/shopping/types/fulfillment_event.json +++ b/source/schemas/shopping/types/fulfillment_event.json @@ -37,7 +37,7 @@ "quantity": { "type": "integer", "minimum": 1, - "description": "Quantity fulfilled in this event." + "description": "Integer count of steps of the referenced line item's `quantity_unit` (`10^-scale` × `unit`); when `quantity_unit` is absent, it counts whole items (`each`)." } } }, diff --git a/source/schemas/shopping/types/item.json b/source/schemas/shopping/types/item.json index e52258b9e..7a6eb908b 100644 --- a/source/schemas/shopping/types/item.json +++ b/source/schemas/shopping/types/item.json @@ -20,9 +20,14 @@ }, "price": { "$ref": "../../common/types/amount.json", - "description": "Unit price in ISO 4217 minor units.", + "description": "Unit price in ISO 4217 minor units. Price is the amount per one whole `quantity_unit.unit` (for example, per lb or per hour); when `quantity_unit` is absent, it is per `each`.", "ucp_request": "omit" }, + "quantity_unit": { + "$ref": "../../common/types/unit.json", + "description": "Sale basis this item's `quantity` is denominated in. On an authoritative Business response, absence encodes the default `each` machine identity (`C62`, 0); the Business MUST include this descriptor for every non-`each` response. On Platform requests, omission makes no assertion: the Business interprets `quantity` using the item's authoritative sale basis. If the Platform includes this descriptor, it asserts a machine identity. The Business MUST compare the machine identity (`unit`, effective `scale`), ignore `display_text`, and reject a mismatch with a recoverable business outcome. An explicit `C62` descriptor at effective scale 0 matches an authoritative basis represented by an absent descriptor.", + "ucp_request": "optional" + }, "image_url": { "type": "string", "description": "Product image URI.", diff --git a/source/schemas/shopping/types/line_item.json b/source/schemas/shopping/types/line_item.json index 0eba9ed05..09a7a3052 100644 --- a/source/schemas/shopping/types/line_item.json +++ b/source/schemas/shopping/types/line_item.json @@ -23,7 +23,7 @@ }, "quantity": { "type": "integer", - "description": "Quantity of the item being purchased.", + "description": "Always an integer step count. On Platform requests, steps use the item's Business-authoritative sale basis; omitting `item.quantity_unit` makes no assertion and does not imply `each`. On Business responses, `item.quantity_unit` describes the basis; if absent, it encodes the `each` machine identity (`C62`, 0) and `quantity` counts whole items.", "minimum": 1 }, "totals": { diff --git a/source/schemas/shopping/types/order_line_item.json b/source/schemas/shopping/types/order_line_item.json index 5f639dcff..0d1e36561 100644 --- a/source/schemas/shopping/types/order_line_item.json +++ b/source/schemas/shopping/types/order_line_item.json @@ -17,7 +17,7 @@ }, "item": { "$ref": "item.json", - "description": "Product data (id, title, price, image_url)." + "description": "Purchased item data, including identity, price, and sale basis." }, "quantity": { "type": "object", @@ -26,20 +26,20 @@ "original": { "type": "integer", "minimum": 0, - "description": "Quantity from the original checkout." + "description": "Quantity from the original checkout, expressed as an integer step count." }, "total": { "type": "integer", "minimum": 0, - "description": "Current total active quantity. May differ from original due to post-order modifications (e.g., returns or cancellations)." + "description": "Current active quantity after returns, cancellations, or other order changes, expressed as an integer step count." }, "fulfilled": { "type": "integer", "minimum": 0, - "description": "Quantity fulfilled so far." + "description": "Quantity fulfilled so far, expressed as an integer step count." } }, - "description": "Quantity tracking for the line item." + "description": "Tracks the line item's original, current active, and fulfilled quantities. All three values use the same inherited `item.quantity_unit`. When `item.quantity_unit` is absent on an authoritative order response, each step is one whole item (`each`) under the shared default." }, "totals": { "type": "array", diff --git a/source/schemas/shopping/types/variant.json b/source/schemas/shopping/types/variant.json index 9355b5f1a..8bc6b7995 100644 --- a/source/schemas/shopping/types/variant.json +++ b/source/schemas/shopping/types/variant.json @@ -63,7 +63,11 @@ }, "price": { "$ref": "../../common/types/price.json", - "description": "Current selling price." + "description": "Current selling price. Price is the amount per one whole `quantity_unit.unit` (for example, per lb or per hour); when `quantity_unit` is absent, it is per `each`. Line total is `price × quantity × 10^-scale`, computed and rounded once by the Business; `totals` remain authoritative." + }, + "quantity_unit": { + "$ref": "../../common/types/unit.json", + "description": "Sale basis this variant's `quantity` is denominated in. The default sale basis is `each`, whose machine identity is (`C62`, 0); `C62` is the UN/CEFACT Rec20 code for one/each. An absent catalog descriptor encodes that default." }, "list_price": { "$ref": "../../common/types/price.json", @@ -71,12 +75,12 @@ }, "unit_price": { "type": "object", - "description": "Price per standard unit of measurement. MAY be omitted when unit pricing does not apply.", + "description": "Price per standard unit of measurement. MAY be omitted when unit pricing does not apply. `unit_price.currency` MUST equal `price.currency`; the comparator MUST NOT perform currency conversion. `measure.unit` and `reference.unit` MUST be identical; cross-unit conversion is not permitted. Their scales MAY differ; each value represents `value × 10^-scale`.", "required": ["amount", "currency", "measure", "reference"], "properties": { "amount": { "$ref": "../../common/types/amount.json", - "description": "Unit price in ISO 4217 minor units. Business MUST return precomputed unit price value: (variant.price / measure.value) * reference.value." + "description": "Unit price in ISO 4217 minor units. After satisfying the same-unit invariant, the Business MUST compute the comparator as `(price.amount / (measure.value × 10^-measure.scale)) × (reference.value × 10^-reference.scale)` and round it once to ISO 4217 minor units according to its pricing rules. The returned `unit_price.amount` is authoritative; the Platform MUST NOT recompute or substitute its own result." }, "currency": { "type": "string", @@ -84,22 +88,36 @@ "description": "ISO 4217 currency code." }, "measure": { - "type": "object", - "description": "Product quantity in packaging (e.g., 750ml bottle).", - "required": ["value", "unit"], - "properties": { - "value": { "type": "number", "description": "Package quantity." }, - "unit": { "type": "string", "description": "Unit of measurement." } - } + "description": "Product quantity in packaging/content (for example, a 750 mL bottle), distinct from `quantity_unit`, which defines the sale basis. Its integer `value` MUST be at least 1.", + "allOf": [ + { + "$ref": "../../common/types/measure.json" + }, + { + "type": "object", + "properties": { + "value": { + "minimum": 1 + } + } + } + ] }, "reference": { - "type": "object", - "description": "Denominator for unit price display (e.g., per 100ml, per 1kg).", - "required": ["value", "unit"], - "properties": { - "value": { "type": "integer", "description": "Reference quantity." }, - "unit": { "type": "string", "description": "Unit of measurement." } - } + "description": "Denominator for unit price display (for example, per 100 mL or per 1 kg). Its integer `value` MUST be at least 1.", + "allOf": [ + { + "$ref": "../../common/types/measure.json" + }, + { + "type": "object", + "properties": { + "value": { + "minimum": 1 + } + } + } + ] } } }, From 81f4dcbbb777d54841cd61f0776bd4a35836f7e1 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Thu, 30 Jul 2026 13:50:03 -0700 Subject: [PATCH 2/5] clarify the sale-basis identity boundary The existing wording could be read as making (`unit`, effective `scale`) the complete identity of a purchasable sale unit. That conflates the denomination used for quantity arithmetic with characteristics that may configure or price the item. Define the pair as the unit descriptor's machine identity only. Keep catalog variant identity separate, clarify that other sale-unit characteristics may affect the Business-quoted price without changing its denominator, and state that Order lifecycle quantity arithmetic uses only inherited sale-basis steps. This is a description-only clarification. It adds no fields or measurement configuration behavior, preserving the current wire contract while leaving that model to a separately negotiated extension. --- docs/specification/catalog/index.md | 6 +++--- docs/specification/checkout.md | 6 ++++-- docs/specification/order.md | 4 ++++ docs/specification/overview.md | 3 ++- source/schemas/common/types/unit.json | 2 +- source/schemas/shopping/types/item.json | 2 +- 6 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/specification/catalog/index.md b/docs/specification/catalog/index.md index 75ae08bfd..126302ee1 100644 --- a/docs/specification/catalog/index.md +++ b/docs/specification/catalog/index.md @@ -64,9 +64,9 @@ transaction. The descriptor follows the shared absence advertises the default `each` basis; the Business advertises a non-`each` basis by including the descriptor. -The variant identity, such as a stock keeping unit (SKU), defines *what* each -step is a step of; `quantity_unit` defines *how much* one step is. Stainless -steel fasteners whose `quantity_unit` is +`variants[].id` identifies the purchasable variant; `quantity_unit` defines the +denomination and granularity used to order it. +Stainless steel fasteners whose `quantity_unit` is `{ "unit": "KGM", "scale": 2, "display_text": "kg" }` are sold in hundredth-of-a-kilogram steps, so a `quantity` of `150` represents 1.50 kg. diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index 94d9b8a6e..4740d5417 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -90,8 +90,10 @@ as a count of whole items. **Pricing a line.** `item.price` is the amount per one whole `quantity_unit.unit` (for example, per kg or per hour); when `quantity_unit` is -absent, it is the price per `each`. The Business **MUST** compute the line total -as `price × quantity × 10^-scale` and round once at the line. The presented +absent, it is the price per `each`. Other characteristics of a sale unit may +affect the quoted `item.price`, but do not change its sale-basis denominator. +The Business **MUST** compute the line total as +`price × quantity × 10^-scale` and round once at the line. The presented `totals[]` remain authoritative (see [Totals](checkout.md#totals)). The Platform **MUST NOT** recompute a line total from the fractional quantity and substitute its own rounding. diff --git a/docs/specification/order.md b/docs/specification/order.md index e0cf11d8f..61cafd256 100644 --- a/docs/specification/order.md +++ b/docs/specification/order.md @@ -62,6 +62,10 @@ Line items reflect what was purchased at checkout and their current state: absent `quantity_unit` means the quantities count whole items (`each`) at `scale` 0 +All order lifecycle arithmetic over quantities operates on sale-basis step +counts inherited from the item; other item characteristics do not enter that +arithmetic. + ### Fulfillment Fulfillment tracks how items are delivered to the buyer. diff --git a/docs/specification/overview.md b/docs/specification/overview.md index a6ff5207e..7629e5a1e 100644 --- a/docs/specification/overview.md +++ b/docs/specification/overview.md @@ -47,7 +47,8 @@ One step is `10^-scale` of `unit`. The shared measure type adds a required integer `value`, which is also a count of those steps. Because these counts are integers, `scale` fixes the representation's granularity. A unit descriptor's machine identity is the (`unit`, effective `scale`) pair; `display_text` is not -part of that identity. +part of that identity. This identity applies only to the unit descriptor; it +does not identify the purchasable item or exhaustively describe one sale unit. The default sale basis is `each`, with machine identity (`C62`, `0`). `C62` is the United Nations Centre for Trade Facilitation and Electronic Business diff --git a/source/schemas/common/types/unit.json b/source/schemas/common/types/unit.json index 5117af19d..b8ccea563 100644 --- a/source/schemas/common/types/unit.json +++ b/source/schemas/common/types/unit.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/common/types/unit.json", "title": "Unit", - "description": "A reusable unit descriptor for quantities and measures. Its machine identity is (`unit`, effective `scale`), where effective `scale` is the provided `scale` or 0; `display_text` is excluded.", + "description": "A reusable unit descriptor for quantities and measures. Its unit-descriptor machine identity is (`unit`, effective `scale`), where effective `scale` is the provided `scale` or 0; `display_text` is excluded.", "type": "object", "required": ["unit", "display_text"], "properties": { diff --git a/source/schemas/shopping/types/item.json b/source/schemas/shopping/types/item.json index 7a6eb908b..2849229c9 100644 --- a/source/schemas/shopping/types/item.json +++ b/source/schemas/shopping/types/item.json @@ -25,7 +25,7 @@ }, "quantity_unit": { "$ref": "../../common/types/unit.json", - "description": "Sale basis this item's `quantity` is denominated in. On an authoritative Business response, absence encodes the default `each` machine identity (`C62`, 0); the Business MUST include this descriptor for every non-`each` response. On Platform requests, omission makes no assertion: the Business interprets `quantity` using the item's authoritative sale basis. If the Platform includes this descriptor, it asserts a machine identity. The Business MUST compare the machine identity (`unit`, effective `scale`), ignore `display_text`, and reject a mismatch with a recoverable business outcome. An explicit `C62` descriptor at effective scale 0 matches an authoritative basis represented by an absent descriptor.", + "description": "Sale basis this item's `quantity` is denominated in. On an authoritative Business response, absence encodes the default `each` machine identity (`C62`, 0); the Business MUST include this descriptor for every non-`each` response. On Platform requests, omission makes no assertion: the Business interprets `quantity` using the item's authoritative sale basis. If the Platform includes this descriptor, it asserts the unit-descriptor machine identity. The Business MUST compare that machine identity (`unit`, effective `scale`), ignore `display_text`, and reject a mismatch with a recoverable business outcome. An explicit `C62` descriptor at effective scale 0 matches an authoritative basis represented by an absent descriptor.", "ucp_request": "optional" }, "image_url": { From 321c8c03edc290bb6f49fdbf6fae012bdfca682c Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Fri, 31 Jul 2026 07:22:26 -0700 Subject: [PATCH 3/5] add ordering increment on the sale-basis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add common/types/quantity_unit.json — the shared unit descriptor composed (allOf) with an optional integer `increment`, a count of scale-steps — and remount variant/item quantity_unit on it. The bare descriptor family is unchanged: measure.json still composes unit.json, so increment cannot appear on unit_price internals or future measures. Increment is advisory merchandising policy, not a representational bound. `scale` bounds what any quantity can express; `increment` shapes what the Platform asks for: Platform-authored quantities SHOULD be increment multiples, and the Business accepts, visibly revises (never silently reinterprets), or rejects an off-increment ask through the standard recoverable-message channel. The Business MAY also revise quantities for its own reasons (e.g. limited stock), keeping revisions on-grid so stepper edits from the revised value stay valid. Business-recorded facts — fulfillment events, adjustments — are bounded only by scale, keeping catch-weight reality representable. Increment is excluded from unit-descriptor machine identity and mismatch comparison. overview.md carries the shared contract, checkout.md the normative behavior plus a snap example, cart.md delegates, order.md scopes recorded facts to scale. --- docs/specification/cart.md | 3 +- docs/specification/checkout.md | 32 ++++++++++++++++++++++ docs/specification/order.md | 7 ++++- docs/specification/overview.md | 23 ++++++++++++++-- source/schemas/shopping/types/item.json | 4 +-- source/schemas/shopping/types/variant.json | 4 +-- 6 files changed, 65 insertions(+), 8 deletions(-) diff --git a/docs/specification/cart.md b/docs/specification/cart.md index b68a20b43..6d492bce9 100644 --- a/docs/specification/cart.md +++ b/docs/specification/cart.md @@ -105,7 +105,8 @@ without asserting `each`. The Platform **MAY** include `item.quantity_unit` to assert the sale-basis identity. Cart follows [Checkout — Quantity and sale basis](checkout.md#quantity-and-sale-basis) -for assertion matching, mismatch handling, response echo, and line pricing. +for assertion matching, mismatch handling, response echo, ordering-increment +handling, and line pricing. Cart totals remain estimates (see [Total](#total)). A measure-denominated line counts as one line item in cart summaries; its `quantity` is an amount, not an item count. diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index 4740d5417..14f6c5f56 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -81,6 +81,38 @@ Platform **MUST NOT** silently convert a quantity between units in either direction. A quantity the receiver cannot read in the authoritative basis is an error to surface, not a value to reinterpret. +**Ordering increment.** The sale basis **MAY** declare an +[`increment`](overview.md#ordering-increment) — the ordering granularity, in +steps, the Business sells in. The declaration lets the Platform build quantity +steppers and validate input before submission. Platform-authored quantities +**SHOULD** be integer multiples of the line's effective increment. The +increment is merchandising policy, not a representational bound, and schema +validation does not enforce it: on receiving an off-increment quantity, the +Business **MAY** accept it, revise the line to an increment multiple, or reject +it with a recoverable business outcome. A revision **MUST** be returned as a +revised line `quantity` with an explanatory `messages[]` entry; the Business +**MUST NOT** silently reinterpret the requested quantity. The Business **MAY** +also revise a quantity for its own reasons (for example, limited stock); such +revisions **SHOULD** stay on the increment grid so subsequent Platform stepper +edits from the revised value remain on-grid. + +For example, bananas sold by the pound in quarter-pound multiples declare +`{ "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 }`. A +request for `quantity` `137` (1.37 lb) is off-increment; a Business that snaps +it returns the line revised to `125` with a warning: + + +```json +[ + { + "type": "warning", + "code": "quantity_increment_revised", + "path": "$.line_items[0].quantity", + "content": "Bananas are sold in 0.25 lb increments. Your requested 1.37 lb was adjusted to 1.25 lb." + } +] +``` + **Echoing the sale basis.** The Business **MAY** omit `quantity_unit` from a response line whose effective sale-basis identity is the default identity. The Business **MUST** include `quantity_unit` on the item in every Cart, Checkout, diff --git a/docs/specification/order.md b/docs/specification/order.md index 61cafd256..b5f7ecc1b 100644 --- a/docs/specification/order.md +++ b/docs/specification/order.md @@ -64,7 +64,12 @@ Line items reflect what was purchased at checkout and their current state: All order lifecycle arithmetic over quantities operates on sale-basis step counts inherited from the item; other item characteristics do not enter that -arithmetic. +arithmetic. Business-recorded quantities — fulfillment events, adjustments, and +revised totals — are bounded only by `scale`: a declared ordering +[`increment`](overview.md#ordering-increment) binds Platform requests at cart +and checkout and does not constrain what the Business records (a 0.25 lb +ordering increment does not prevent recording an actual picked weight of +1.48 lb). ### Fulfillment diff --git a/docs/specification/overview.md b/docs/specification/overview.md index 7629e5a1e..116156200 100644 --- a/docs/specification/overview.md +++ b/docs/specification/overview.md @@ -97,8 +97,27 @@ When sending a unit descriptor, a Business or Platform **MUST** include localized label. The Business and Platform **MUST NOT** use `display_text` when matching machine identities or as an input to quantity conversion. -Request assertions, mismatch handling, response echo, pricing, and lifecycle -behavior are defined by the capability that uses the shared representation. +### Ordering increment + +A sale-basis descriptor (`quantity_unit`) **MAY** declare an `increment`: an +optional positive integer, denominated in steps, whose effective value is the +provided value or `1` when omitted. Only the sale basis carries an increment; +the bare unit descriptor and the shared measure type do not. It declares the +ordering granularity the Business sells in — for example, a pound-denominated +item with `scale` `2` and `increment` `25` is sold in 0.25 lb multiples. + +`scale` and `increment` play different roles: `scale` bounds what any quantity +can express; `increment` shapes what the Platform asks for. The increment is +advisory merchandising policy, not a representational bound — Platform-authored +quantities **SHOULD** be integer multiples of the line's effective increment, +while Business-authored quantities (checkout revisions, fulfillment events, +adjustments) are bounded only by `scale`. `increment` is not part of the +unit-descriptor machine identity and **MUST NOT** participate in mismatch +comparison. + +Request assertions, mismatch handling, response echo, off-increment request +handling, pricing, and lifecycle behavior are defined by the capability that +uses the shared representation. ## Actions diff --git a/source/schemas/shopping/types/item.json b/source/schemas/shopping/types/item.json index 2849229c9..4afda00cf 100644 --- a/source/schemas/shopping/types/item.json +++ b/source/schemas/shopping/types/item.json @@ -24,8 +24,8 @@ "ucp_request": "omit" }, "quantity_unit": { - "$ref": "../../common/types/unit.json", - "description": "Sale basis this item's `quantity` is denominated in. On an authoritative Business response, absence encodes the default `each` machine identity (`C62`, 0); the Business MUST include this descriptor for every non-`each` response. On Platform requests, omission makes no assertion: the Business interprets `quantity` using the item's authoritative sale basis. If the Platform includes this descriptor, it asserts the unit-descriptor machine identity. The Business MUST compare that machine identity (`unit`, effective `scale`), ignore `display_text`, and reject a mismatch with a recoverable business outcome. An explicit `C62` descriptor at effective scale 0 matches an authoritative basis represented by an absent descriptor.", + "$ref": "../../common/types/quantity_unit.json", + "description": "Sale basis this item's `quantity` is denominated in. On an authoritative Business response, absence encodes the default `each` machine identity (`C62`, 0); the Business MUST include this descriptor for every non-`each` response. On Platform requests, omission makes no assertion: the Business interprets `quantity` using the item's authoritative sale basis. If the Platform includes this descriptor, it asserts the unit-descriptor machine identity. The Business MUST compare that machine identity (`unit`, effective `scale`), ignore `display_text` and `increment`, and reject a mismatch with a recoverable business outcome. An explicit `C62` descriptor at effective scale 0 matches an authoritative basis represented by an absent descriptor.", "ucp_request": "optional" }, "image_url": { diff --git a/source/schemas/shopping/types/variant.json b/source/schemas/shopping/types/variant.json index 8bc6b7995..03996c289 100644 --- a/source/schemas/shopping/types/variant.json +++ b/source/schemas/shopping/types/variant.json @@ -66,8 +66,8 @@ "description": "Current selling price. Price is the amount per one whole `quantity_unit.unit` (for example, per lb or per hour); when `quantity_unit` is absent, it is per `each`. Line total is `price × quantity × 10^-scale`, computed and rounded once by the Business; `totals` remain authoritative." }, "quantity_unit": { - "$ref": "../../common/types/unit.json", - "description": "Sale basis this variant's `quantity` is denominated in. The default sale basis is `each`, whose machine identity is (`C62`, 0); `C62` is the UN/CEFACT Rec20 code for one/each. An absent catalog descriptor encodes that default." + "$ref": "../../common/types/quantity_unit.json", + "description": "Sale basis this variant's `quantity` is denominated in. The default sale basis is `each`, whose machine identity is (`C62`, 0); `C62` is the UN/CEFACT Rec20 code for one/each. An absent catalog descriptor encodes that default. An `increment` advertises the ordering granularity in steps (for example, `scale` 2 with `increment` 25 sells in 0.25-unit multiples)." }, "list_price": { "$ref": "../../common/types/price.json", From 66bf5aa2db7fdec5c09d698be3afae9ff924327c Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Fri, 31 Jul 2026 08:24:49 -0700 Subject: [PATCH 4/5] ordering increment, sale-basis discovery, and mismatch conversion Add an optional integer `increment` to the sale-basis descriptor (common/types/quantity_unit.json, composing the shared unit descriptor via allOf; variant/item remount onto it). Increment is a count of scale-steps and is advisory merchandising policy: it bounds what the Platform asks for, while scale bounds what any quantity can express. Platform-authored quantities SHOULD be increment multiples; the Business accepts, visibly revises, or rejects off-increment asks; and Business-recorded facts (fulfillment events, adjustments, revisions) are bounded only by scale. Increment is excluded from machine identity and never appears on measure/unit_price internals. Define the negotiation model around the descriptor: - Discovery: the Platform SHOULD learn the sale basis (unit, scale, increment) from the catalog; without it, omit the descriptor and read the authoritative basis from the response echo. Assertion verifies a previously discovered basis - omit rather than guess. - Mismatch: silent conversion remains forbidden. The Business MAY convert an asserted basis to its authoritative basis as a visible line revision with a warning, else MUST reject recoverably (update: line unchanged; create: line not created). UCP defines no conversion factors or dimensions; whether to convert is the Business's own determination. - Catch-weight: picked-vs-ordered variance reconciles through adjustments that move money together with quantity; fulfilled == total then holds exactly, and no rounding tolerances or epsilon comparisons exist anywhere in the quantity lifecycle. Docs: overview leads with the no-floating-point rationale (quantity arithmetic feeds money) and the zero-arithmetic rendering recipe (shift by scale, append display_text); checkout shows one mismatched update answered two ways - conversion vs rejection - so the machine-readable recovery path (the echoed descriptor, never message content) is visible in the JSON; order adds a catch-weight worked example; catalog advertises the increment as part of discovery. All worked examples follow a single SKU (fasteners, KGM/scale 2, increment 25) end to end; pounds appear only as the stale wrong assertion in the mismatch exhibit. --- .cspell/custom-words.txt | 1 + docs/specification/cart.md | 4 +- docs/specification/catalog/index.md | 14 +- docs/specification/checkout.md | 186 +++++++++++++++--- docs/specification/glossary.md | 3 +- docs/specification/order.md | 83 +++++++- docs/specification/overview.md | 25 ++- .../schemas/common/types/quantity_unit.json | 22 +++ source/schemas/shopping/types/item.json | 2 +- 9 files changed, 296 insertions(+), 44 deletions(-) create mode 100644 source/schemas/common/types/quantity_unit.json diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index d028a6899..989fa438d 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -160,3 +160,4 @@ punycode userinfo examplecorp zapatillas +recoverably diff --git a/docs/specification/cart.md b/docs/specification/cart.md index 6d492bce9..982f3c07c 100644 --- a/docs/specification/cart.md +++ b/docs/specification/cart.md @@ -105,8 +105,8 @@ without asserting `each`. The Platform **MAY** include `item.quantity_unit` to assert the sale-basis identity. Cart follows [Checkout — Quantity and sale basis](checkout.md#quantity-and-sale-basis) -for assertion matching, mismatch handling, response echo, ordering-increment -handling, and line pricing. +for sale-basis discovery, assertion matching, mismatch conversion or +rejection, response echo, ordering-increment handling, and line pricing. Cart totals remain estimates (see [Total](#total)). A measure-denominated line counts as one line item in cart summaries; its `quantity` is an amount, not an item count. diff --git a/docs/specification/catalog/index.md b/docs/specification/catalog/index.md index 126302ee1..7db83a1fb 100644 --- a/docs/specification/catalog/index.md +++ b/docs/specification/catalog/index.md @@ -64,6 +64,14 @@ transaction. The descriptor follows the shared absence advertises the default `each` basis; the Business advertises a non-`each` basis by including the descriptor. +The catalog is where the Platform learns the sale basis before transacting: +`unit` and `scale` define how quantities are denominated, and an optional +[`increment`](../overview.md#ordering-increment) advertises the ordering +granularity the Business sells in, letting the Platform build quantity +steppers and validate input before submission. A Business selling loose +fasteners by the kilogram in quarter-kilogram multiples advertises +`{ "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 }`. + `variants[].id` identifies the purchasable variant; `quantity_unit` defines the denomination and granularity used to order it. Stainless steel fasteners whose `quantity_unit` is @@ -123,8 +131,8 @@ carrying a `unit_price` per metre. Its `measure` can be A `get_product` response for loose metal fasteners sold by the kilogram. The variant advertises `quantity_unit` -`{ "unit": "KGM", "scale": 2, "display_text": "kg" }` and a `price` of `1299` — -$12.99 per whole kilogram: +`{ "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 }` and a +`price` of `1299` — $12.99 per whole kilogram, sold in 0.25-kg multiples: ```json @@ -144,7 +152,7 @@ $12.99 per whole kilogram: "title": "Stainless Steel Fasteners", "description": { "plain": "Fasteners sold by the kilogram." }, "price": { "amount": 1299, "currency": "USD" }, - "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg" }, + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 }, "availability": { "available": true } } ] diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index 14f6c5f56..dfc8cf6f2 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -59,6 +59,18 @@ default (`C62`, `0`) identity. The Business determines each item's authoritative sale basis for the transaction. The Business's response is authoritative, and each `line_items[].quantity` is an integer step count in that basis. +**Discovering the sale basis.** The sale basis is item data: the Platform +**SHOULD** discover an item's `quantity_unit` — including `scale` and any +`increment` — from the +[catalog](catalog/index.md#sale-basis-and-quantity-units) before transacting. +A Platform without catalog knowledge can omit the descriptor: the Business +applies its authoritative basis and confirms it on the response line, and the +Platform inspects the echoed descriptor and, if that interpretation is not +what it intended, resubmits the quantity denominated in the now-known basis. +Asserting `quantity_unit` on a request (below) is how the Platform checks that +a basis it previously discovered still holds; a Platform that does not know +the basis omits the descriptor rather than asserting a guess. + **Requesting a quantity.** When a Platform request omits `line_items[].item.quantity_unit`, the Platform makes no assertion about the sale-basis identity. The Business interprets `quantity` using the item's @@ -74,12 +86,139 @@ represented by an absent descriptor; a `display_text` difference is not a mismatch. If an asserted `quantity_unit` does not match the item's authoritative sale -basis, the Business **MUST** reject that line with a recoverable business outcome -that names the authoritative unit — a `messages[]` entry on a `200` response, -not a transport error (see [Error Handling](#error-handling)). The Business and -Platform **MUST NOT** silently convert a quantity between units in either -direction. A quantity the receiver cannot read in the authoritative basis is an -error to surface, not a value to reinterpret. +basis, the requested quantity is denominated in a basis the Business does not +sell in. Silent conversion is forbidden in both directions: neither party may +reinterpret a quantity the other denominated without surfacing the change. The +Business resolves the mismatch in one of two ways: + +* **Convert, visibly.** When the Business can convert the asserted + basis to its authoritative basis (for example, pounds to kilograms), it + **MAY** apply the request as a visible line revision: the line carries the + authoritative descriptor and the converted quantity — rounded once to the + authoritative `scale` according to its rules, with any ordering-increment + policy applied after conversion — and the response includes a warning + `messages[]` entry at the line. UCP defines no conversion factors or + dimensions; whether to convert is the Business's own determination. +* **Reject, recoverably.** When it cannot or chooses not to convert (for + example, a quantity of fasteners denominated in litres), the Business + **MUST** reject the request's effect on that line with a recoverable + business outcome naming the authoritative basis. On update, the line + remains at its previous authoritative state. On create, the line is not created and the + message identifies the item in `content`; when no line can be created, the + Business **MAY** return an error response instead. + +In both cases the response reflects authoritative state, never an echo of +rejected input, and every line present carries its authoritative sale basis +under the response-echo rule. The Platform recovers by reading the echoed +descriptor — or re-reading the catalog — and resubmitting; the message +`content` is explanatory text for humans, never the recovery input. + +For example, stale data leads a Platform to believe an item sold by the +kilogram is sold by the pound. It submits an update asserting that basis: + + +```json +{ + "line_items": [ + { + "id": "li_fasteners", + "item": { + "id": "var_fasteners", + "quantity_unit": { "unit": "LBR", "scale": 2, "display_text": "lb" } + }, + "quantity": 275 + } + ] +} +``` + +A Business that converts applies the update as a visible revision. +The line is denominated in the authoritative basis — the requested 2.75 lb +converts to 1.25 kg, rounded once to `scale` 2 and landing on the item's +0.25-kg ordering increment — and a warning marks the revision: + + +```json +{ + "ucp": { "version": "{{ ucp_version }}", "status": "success", "payment_handlers": {} }, + "id": "chk_fasteners_1", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li_fasteners", + "item": { + "id": "var_fasteners", + "title": "Stainless Steel Fasteners", + "price": 1299, + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 } + }, + "quantity": 125, + "totals": [ + { "type": "subtotal", "amount": 1624 }, + { "type": "total", "amount": 1624 } + ] + } + ], + "messages": [ + { + "type": "warning", + "code": "quantity_unit_converted", + "path": "$.line_items[0].quantity", + "content": "This item is sold by the kilogram. The requested 2.75 lb was converted to 1.25 kg." + } + ], + "totals": [ + { "type": "subtotal", "amount": 1624 }, + { "type": "total", "amount": 1624 } + ], + "links": [] +} +``` + +A Business that does not convert rejects the update instead. The line remains +unchanged — `quantity` stays `150` (1.50 kg), not a reinterpretation of the +requested `275` — and the error names the authoritative basis: + + +```json +{ + "ucp": { "version": "{{ ucp_version }}", "status": "success", "payment_handlers": {} }, + "id": "chk_fasteners_1", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li_fasteners", + "item": { + "id": "var_fasteners", + "title": "Stainless Steel Fasteners", + "price": 1299, + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 } + }, + "quantity": 150, + "totals": [ + { "type": "subtotal", "amount": 1949 }, + { "type": "total", "amount": 1949 } + ] + } + ], + "messages": [ + { + "type": "error", + "code": "quantity_unit_mismatch", + "severity": "recoverable", + "path": "$.line_items[0].item.quantity_unit", + "content": "This item is sold by the kilogram, not by the pound. Resubmit the quantity as a count of 0.01-kg steps." + } + ], + "totals": [ + { "type": "subtotal", "amount": 1949 }, + { "type": "total", "amount": 1949 } + ], + "links": [] +} +``` **Ordering increment.** The sale basis **MAY** declare an [`increment`](overview.md#ordering-increment) — the ordering granularity, in @@ -96,10 +235,10 @@ also revise a quantity for its own reasons (for example, limited stock); such revisions **SHOULD** stay on the increment grid so subsequent Platform stepper edits from the revised value remain on-grid. -For example, bananas sold by the pound in quarter-pound multiples declare -`{ "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 }`. A -request for `quantity` `137` (1.37 lb) is off-increment; a Business that snaps -it returns the line revised to `125` with a warning: +For example, the fasteners above are sold in quarter-kilogram multiples +(`increment` `25` at `scale` `2`). A request for `quantity` `137` (1.37 kg) is +off-increment; a Business that snaps it returns the line revised to `125` with +a warning: ```json @@ -108,7 +247,7 @@ it returns the line revised to `125` with a warning: "type": "warning", "code": "quantity_increment_revised", "path": "$.line_items[0].quantity", - "content": "Bananas are sold in 0.25 lb increments. Your requested 1.37 lb was adjusted to 1.25 lb." + "content": "This item is sold in 0.25 kg increments. Your requested 1.37 kg was adjusted to 1.25 kg." } ] ``` @@ -132,9 +271,9 @@ its own rounding. For example, a Business sells loose stainless steel fasteners by the kilogram with `quantity_unit` -`{ "unit": "KGM", "scale": 2, "display_text": "kg" }` and a `price` of `1299` -($12.99/kg). A Buyer orders 1.50 kg, so the Platform sends `quantity` `150` — 150 -hundredth-of-a-kilogram steps. The line total is +`{ "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 }` and a +`price` of `1299` ($12.99/kg). A Buyer orders 1.50 kg, so the Platform sends +`quantity` `150` — 150 hundredth-of-a-kilogram steps. The line total is `1299 × 150 × 10^-2 = 1948.5`, rounded once to `1949` ($19.49): @@ -151,7 +290,7 @@ hundredth-of-a-kilogram steps. The line total is "id": "var_fasteners", "title": "Stainless Steel Fasteners", "price": 1299, - "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg" } + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 } }, "quantity": 150, "totals": [ @@ -168,23 +307,6 @@ hundredth-of-a-kilogram steps. The line total is } ``` -If the Platform instead asserts a unit the Business does not sell the item in, -the Business returns the current Checkout with a recoverable message that names -the authoritative unit and leaves the quantity for the Platform to resubmit: - - -```json -[ - { - "type": "error", - "code": "quantity_unit_mismatch", - "severity": "recoverable", - "path": "$.line_items[0].item.quantity_unit", - "content": "This item is sold by the kilogram (KGM). Resubmit the quantity in 0.01-kg steps." - } -] -``` - ### Checkout Status Lifecycle The checkout `status` field indicates the current phase of the session and diff --git a/docs/specification/glossary.md b/docs/specification/glossary.md index ac9dd4f21..32f74e075 100644 --- a/docs/specification/glossary.md +++ b/docs/specification/glossary.md @@ -43,10 +43,11 @@ acronym in each specification Markdown file spells out the full term (e.g., | Term | Acronym | Definition | | :--------------------------- | :------ | :---------------------------------------------------------------------------------------------------------------------------------------------------- | | **Business** | - | The entity selling goods or services. In UCP, they act as the **Merchant of Record (MoR)**, retaining financial liability and ownership of the order. | +| **Increment** | - | Optional [ordering granularity](overview.md#ordering-increment) of a sale basis, a count of steps; advisory for Platform-authored quantities. | | **Merchant of Record** | MoR | The legal entity responsible for the sale, including financial liability and order ownership. | | **Payment Service Provider** | PSP | The financial infrastructure provider that processes payments, authorizations, and settlements on behalf of the business. | | **Platform** | - | The consumer-facing surface (AI agent, app, website) acting on behalf of the user to discover businesses and facilitate commerce. | -| **Quantity Unit** | - | A [sale-basis descriptor](overview.md#quantities-and-units) with machine identity (`unit`, effective `scale`); `display_text` is excluded. | +| **Quantity Unit** | - | A [sale-basis descriptor](overview.md#quantities-and-units) with machine identity (`unit`, effective `scale`); excludes `display_text`/`increment`. | | **Step** | - | One [integer increment](overview.md#quantities-and-units) in the authoritative sale basis: `10^-scale` of `unit`. | ## Payments diff --git a/docs/specification/order.md b/docs/specification/order.md index b5f7ecc1b..ff395d56d 100644 --- a/docs/specification/order.md +++ b/docs/specification/order.md @@ -67,9 +67,9 @@ counts inherited from the item; other item characteristics do not enter that arithmetic. Business-recorded quantities — fulfillment events, adjustments, and revised totals — are bounded only by `scale`: a declared ordering [`increment`](overview.md#ordering-increment) binds Platform requests at cart -and checkout and does not constrain what the Business records (a 0.25 lb +and checkout and does not constrain what the Business records (a 0.25-kg ordering increment does not prevent recording an actual picked weight of -1.48 lb). +1.45 kg). ### Fulfillment @@ -328,7 +328,7 @@ rounded once — the line total is `1299 × 1.50 = 1949`, and the return credits "id": "var_fasteners", "title": "Stainless Steel Fasteners", "price": 1299, - "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg" } + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 } }, "quantity": { "original": 150, "total": 150, "fulfilled": 50 }, "totals": [ @@ -369,6 +369,83 @@ rounded once — the line total is `1299 × 1.50 = 1949`, and the return credits } ``` +## Example: catch-weight reconciliation + +For weighed goods, the picked weight routinely differs from the ordered +weight. The difference is a commercial fact, not a numeric error: the Business +records the actual pick and reconciles the difference with an +[adjustment](#adjustments) that moves money together with quantity. No +tolerance comparison is involved; the status derivation operates on exact step +counts throughout. + +A Buyer orders 1.50 kg of the same loose fasteners at $12.99/kg, sold in +quarter-kilogram increments (`quantity` `150`, line total +`1299 × 1.50 = 1948.5`, rounded once to `1949`). The picker weighs out +1.45 kg — an off-increment fact, recorded as-is, because the +[`increment`](overview.md#ordering-increment) binds Platform ordering, not +Business records. The fulfillment event records the actual `145` steps, and a +`price_adjustment` of `-5` steps reconciles `total` to the actual pick with +its price delta (`1299 × 0.05 = 64.95`, rounded once to `65`). +`fulfilled == total` then holds exactly (`145 == 145`) and the line derives +`fulfilled`: + + +```json +{ + "ucp": { + "version": "{{ ucp_version }}", + "capabilities": { "dev.ucp.shopping.order": [{"version": "{{ ucp_version }}"}] } + }, + "id": "order_fasteners_2", + "checkout_id": "chk_fasteners_2", + "permalink_url": "https://business.example.com/orders/fasteners2", + "currency": "USD", + "line_items": [ + { + "id": "li_fasteners", + "item": { + "id": "var_fasteners", + "title": "Stainless Steel Fasteners", + "price": 1299, + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 } + }, + "quantity": { "original": 150, "total": 145, "fulfilled": 145 }, + "totals": [ + { "type": "subtotal", "amount": 1949 }, + { "type": "total", "amount": 1949 } + ], + "status": "fulfilled" + } + ], + "fulfillment": { + "events": [ + { + "id": "evt_1", + "occurred_at": "2026-01-12T09:15:00Z", + "type": "shipped", + "line_items": [{ "id": "li_fasteners", "quantity": 145 }], + "description": "Picked 1.45 kg" + } + ] + }, + "adjustments": [ + { + "id": "adj_1", + "type": "price_adjustment", + "occurred_at": "2026-01-12T09:15:00Z", + "status": "completed", + "line_items": [{ "id": "li_fasteners", "quantity": -5 }], + "totals": [{ "type": "total", "amount": -65 }], + "description": "Adjusted to actual picked weight of 1.45 kg" + } + ], + "totals": [ + { "type": "subtotal", "amount": 1949 }, + { "type": "total", "amount": 1949 } + ] +} +``` + ## Scopes The Order capability defines the following well-known scopes for diff --git a/docs/specification/overview.md b/docs/specification/overview.md index 116156200..bfe7d67a3 100644 --- a/docs/specification/overview.md +++ b/docs/specification/overview.md @@ -58,6 +58,27 @@ encode this default. When a Business or Platform includes a descriptor whose `unit` is `C62`, it **MUST** use an effective `scale` of `0`; `scale` can only be omitted or explicitly set to `0`. +UCP does not put floating-point numbers on the wire. Quantity arithmetic +feeds money — `price × quantity × 10^-scale` prices a line, `fulfilled` +accumulates across fulfillment events, and status derives from +`fulfilled == total` — so quantities get money's representation: an integer +count plus a declared interpretation, exactly as an `amount` relates to its +`currency`. Integer counts keep every total and comparison exact in every +language, and UCP therefore defines no rounding tolerances and no epsilon +comparisons anywhere in the quantity lifecycle. A fulfilled quantity that +legitimately differs from the ordered quantity — a 1.45 kg pick against a +1.50 kg order — is a commercial fact reconciled through +[adjustments](order.md#adjustments) that move money together with quantity, +not a numeric error absorbed by comparison fuzz. + +Reading a quantity requires no arithmetic and no unit knowledge: shift the +decimal point `scale` places and append `display_text`. `150` with +`{ "scale": 2, "display_text": "kg" }` renders as `1.50 kg`, by the same code +path for a Rec20 code and for a custom unit. Unlike a currency exponent, +`scale` is per-item data rather than a static table — which is why +authoritative responses always carry their own descriptor on every non-`each` +line. + ### Unit vocabulary The Business **SHOULD** use the exact Rec20 Common Code unless no code @@ -103,8 +124,8 @@ A sale-basis descriptor (`quantity_unit`) **MAY** declare an `increment`: an optional positive integer, denominated in steps, whose effective value is the provided value or `1` when omitted. Only the sale basis carries an increment; the bare unit descriptor and the shared measure type do not. It declares the -ordering granularity the Business sells in — for example, a pound-denominated -item with `scale` `2` and `increment` `25` is sold in 0.25 lb multiples. +ordering granularity the Business sells in — for example, an item sold by the +kilogram with `scale` `2` and `increment` `25` is sold in 0.25 kg multiples. `scale` and `increment` play different roles: `scale` bounds what any quantity can express; `increment` shapes what the Platform asks for. The increment is diff --git a/source/schemas/common/types/quantity_unit.json b/source/schemas/common/types/quantity_unit.json new file mode 100644 index 000000000..17e3848b9 --- /dev/null +++ b/source/schemas/common/types/quantity_unit.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/quantity_unit.json", + "title": "Quantity Unit", + "description": "Sale-basis descriptor for quantities: the shared unit descriptor plus the Business's ordering policy. Its unit-descriptor machine identity remains (`unit`, effective `scale`); `display_text` and `increment` are excluded from identity and mismatch comparison.", + "allOf": [ + { + "$ref": "unit.json" + }, + { + "type": "object", + "properties": { + "increment": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Ordering granularity, denominated in steps: the Business sells this item in integer multiples of `increment` steps. Its effective value is the provided value or 1. Advisory merchandising policy, not a representational bound: Platform-authored quantities SHOULD be integer multiples of the effective increment; the Business MAY accept, revise, or reject an off-increment request with a recoverable business outcome and MUST NOT silently reinterpret it. Business-authored quantities (checkout revisions, fulfillment events, adjustments) are bounded only by `scale`." + } + } + } + ] +} diff --git a/source/schemas/shopping/types/item.json b/source/schemas/shopping/types/item.json index 4afda00cf..2c51a4ea9 100644 --- a/source/schemas/shopping/types/item.json +++ b/source/schemas/shopping/types/item.json @@ -25,7 +25,7 @@ }, "quantity_unit": { "$ref": "../../common/types/quantity_unit.json", - "description": "Sale basis this item's `quantity` is denominated in. On an authoritative Business response, absence encodes the default `each` machine identity (`C62`, 0); the Business MUST include this descriptor for every non-`each` response. On Platform requests, omission makes no assertion: the Business interprets `quantity` using the item's authoritative sale basis. If the Platform includes this descriptor, it asserts the unit-descriptor machine identity. The Business MUST compare that machine identity (`unit`, effective `scale`), ignore `display_text` and `increment`, and reject a mismatch with a recoverable business outcome. An explicit `C62` descriptor at effective scale 0 matches an authoritative basis represented by an absent descriptor.", + "description": "Sale basis this item's `quantity` is denominated in. On an authoritative Business response, absence encodes the default `each` machine identity (`C62`, 0); the Business MUST include this descriptor for every non-`each` response. On Platform requests, omission makes no assertion: the Business interprets `quantity` using the item's authoritative sale basis. If the Platform includes this descriptor, it asserts the unit-descriptor machine identity. The Business MUST compare that machine identity (`unit`, effective `scale`), ignore `display_text` and `increment`, and resolve a mismatch by conversion surfaced as a visible line revision with a warning, or by rejection with a recoverable business outcome; silent reinterpretation is forbidden. An explicit `C62` descriptor at effective scale 0 matches an authoritative basis represented by an absent descriptor.", "ucp_request": "optional" }, "image_url": { From 98b14813b6cfbecc85b9e13d154c04bef23a42f2 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Fri, 31 Jul 2026 08:43:06 -0700 Subject: [PATCH 5/5] lead worked examples with grocery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recast the sale-basis examples around the predominant use case: one grocery SKU — bananas at LBR/scale 2/increment 25, $0.79/lb — now runs end to end through catalog advertisement, checkout pricing, increment snap, partial fulfillment with return, and catch-weight reconciliation. The unit-conversion exhibit keeps an industrial cameo (fasteners sold by the kilogram, no increment), which both shows the model beyond grocery and keeps the mismatch pair free of increment interplay. A catalog note adds that metered offerings (MIN, HUR) ride the same contract, and overview snippets align to the lead (0.25 lb increment illustration; 1.90 lb pick against a 2.00 lb order). --- docs/specification/catalog/index.md | 44 ++++++----- docs/specification/checkout.md | 51 ++++++------- docs/specification/order.md | 110 ++++++++++++++-------------- docs/specification/overview.md | 6 +- 4 files changed, 107 insertions(+), 104 deletions(-) diff --git a/docs/specification/catalog/index.md b/docs/specification/catalog/index.md index 7db83a1fb..6e7f9e388 100644 --- a/docs/specification/catalog/index.md +++ b/docs/specification/catalog/index.md @@ -68,15 +68,19 @@ The catalog is where the Platform learns the sale basis before transacting: `unit` and `scale` define how quantities are denominated, and an optional [`increment`](../overview.md#ordering-increment) advertises the ordering granularity the Business sells in, letting the Platform build quantity -steppers and validate input before submission. A Business selling loose -fasteners by the kilogram in quarter-kilogram multiples advertises -`{ "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 }`. +steppers and validate input before submission. A Business selling bananas by +the pound in quarter-pound multiples advertises +`{ "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 }`. + +The sale basis is not limited to physical measure: metered offerings — +parking by the minute, labor by the hour — use the same descriptor (`MIN`, +`HUR`) with the same contract. `variants[].id` identifies the purchasable variant; `quantity_unit` defines the denomination and granularity used to order it. -Stainless steel fasteners whose `quantity_unit` is -`{ "unit": "KGM", "scale": 2, "display_text": "kg" }` are sold in -hundredth-of-a-kilogram steps, so a `quantity` of `150` represents 1.50 kg. +Bananas whose `quantity_unit` is +`{ "unit": "LBR", "scale": 2, "display_text": "lb" }` are sold in +hundredth-of-a-pound steps, so a `quantity` of `150` represents 1.50 lb. ### Distinction from unit price @@ -129,30 +133,30 @@ carrying a `unit_price` per metre. Its `measure` can be ### Example: a good sold by weight -A `get_product` response for loose metal fasteners sold by the kilogram. The -variant advertises `quantity_unit` -`{ "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 }` and a -`price` of `1299` — $12.99 per whole kilogram, sold in 0.25-kg multiples: +A `get_product` response for bananas sold by the pound. The variant advertises +`quantity_unit` +`{ "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 }` and a +`price` of `79` — $0.79 per whole pound, sold in 0.25-lb multiples: ```json { "ucp": { "version": "{{ ucp_version }}" }, "product": { - "id": "prod_fasteners", - "title": "Stainless Steel Fasteners", - "description": { "plain": "Fasteners sold by the kilogram." }, + "id": "prod_bananas", + "title": "Bananas", + "description": { "plain": "Fresh bananas sold by the pound." }, "price_range": { - "min": { "amount": 1299, "currency": "USD" }, - "max": { "amount": 1299, "currency": "USD" } + "min": { "amount": 79, "currency": "USD" }, + "max": { "amount": 79, "currency": "USD" } }, "variants": [ { - "id": "var_fasteners", - "title": "Stainless Steel Fasteners", - "description": { "plain": "Fasteners sold by the kilogram." }, - "price": { "amount": 1299, "currency": "USD" }, - "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 }, + "id": "var_bananas", + "title": "Bananas", + "description": { "plain": "Fresh bananas sold by the pound." }, + "price": { "amount": 79, "currency": "USD" }, + "quantity_unit": { "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 }, "availability": { "available": true } } ] diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index dfc8cf6f2..6fcd61a63 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -113,8 +113,9 @@ under the response-echo rule. The Platform recovers by reading the echoed descriptor — or re-reading the catalog — and resubmitting; the message `content` is explanatory text for humans, never the recovery input. -For example, stale data leads a Platform to believe an item sold by the -kilogram is sold by the pound. It submits an update asserting that basis: +For example, an industrial supplier sells loose fasteners by the kilogram, +but stale data leads a Platform to believe they are sold by the pound. It +submits an update asserting that basis: ```json @@ -134,8 +135,8 @@ kilogram is sold by the pound. It submits an update asserting that basis: A Business that converts applies the update as a visible revision. The line is denominated in the authoritative basis — the requested 2.75 lb -converts to 1.25 kg, rounded once to `scale` 2 and landing on the item's -0.25-kg ordering increment — and a warning marks the revision: +converts to 1.25 kg, rounded once to `scale` 2 — and a warning marks the +revision: ```json @@ -151,7 +152,7 @@ converts to 1.25 kg, rounded once to `scale` 2 and landing on the item's "id": "var_fasteners", "title": "Stainless Steel Fasteners", "price": 1299, - "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 } + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg" } }, "quantity": 125, "totals": [ @@ -194,7 +195,7 @@ requested `275` — and the error names the authoritative basis: "id": "var_fasteners", "title": "Stainless Steel Fasteners", "price": 1299, - "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 } + "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg" } }, "quantity": 150, "totals": [ @@ -235,8 +236,8 @@ also revise a quantity for its own reasons (for example, limited stock); such revisions **SHOULD** stay on the increment grid so subsequent Platform stepper edits from the revised value remain on-grid. -For example, the fasteners above are sold in quarter-kilogram multiples -(`increment` `25` at `scale` `2`). A request for `quantity` `137` (1.37 kg) is +For example, bananas sold by the pound in quarter-pound multiples +(`increment` `25` at `scale` `2`). A request for `quantity` `137` (1.37 lb) is off-increment; a Business that snaps it returns the line revised to `125` with a warning: @@ -247,7 +248,7 @@ a warning: "type": "warning", "code": "quantity_increment_revised", "path": "$.line_items[0].quantity", - "content": "This item is sold in 0.25 kg increments. Your requested 1.37 kg was adjusted to 1.25 kg." + "content": "This item is sold in 0.25 lb increments. Your requested 1.37 lb was adjusted to 1.25 lb." } ] ``` @@ -269,39 +270,39 @@ The Business **MUST** compute the line total as **MUST NOT** recompute a line total from the fractional quantity and substitute its own rounding. -For example, a Business sells loose stainless steel fasteners by the kilogram -with `quantity_unit` -`{ "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 }` and a -`price` of `1299` ($12.99/kg). A Buyer orders 1.50 kg, so the Platform sends -`quantity` `150` — 150 hundredth-of-a-kilogram steps. The line total is -`1299 × 150 × 10^-2 = 1948.5`, rounded once to `1949` ($19.49): +For example, a Business sells bananas by the pound with `quantity_unit` +`{ "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 }` and a +`price` of `79` ($0.79/lb). A Buyer orders 1.50 lb, so the Platform sends +`quantity` `150` — 150 hundredth-of-a-pound steps. The line total is +`79 × 150 × 10^-2 = 118.5`, rounded once per its pricing rules to `119` +($1.19): ```json { "ucp": { "version": "{{ ucp_version }}", "status": "success", "payment_handlers": {} }, - "id": "chk_fasteners_1", + "id": "chk_bananas_1", "status": "incomplete", "currency": "USD", "line_items": [ { - "id": "li_fasteners", + "id": "li_bananas", "item": { - "id": "var_fasteners", - "title": "Stainless Steel Fasteners", - "price": 1299, - "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 } + "id": "var_bananas", + "title": "Bananas", + "price": 79, + "quantity_unit": { "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 } }, "quantity": 150, "totals": [ - { "type": "subtotal", "amount": 1949 }, - { "type": "total", "amount": 1949 } + { "type": "subtotal", "amount": 119 }, + { "type": "total", "amount": 119 } ] } ], "totals": [ - { "type": "subtotal", "amount": 1949 }, - { "type": "total", "amount": 1949 } + { "type": "subtotal", "amount": 119 }, + { "type": "total", "amount": 119 } ], "links": [] } diff --git a/docs/specification/order.md b/docs/specification/order.md index ff395d56d..92b6b9b79 100644 --- a/docs/specification/order.md +++ b/docs/specification/order.md @@ -67,9 +67,9 @@ counts inherited from the item; other item characteristics do not enter that arithmetic. Business-recorded quantities — fulfillment events, adjustments, and revised totals — are bounded only by `scale`: a declared ordering [`increment`](overview.md#ordering-increment) binds Platform requests at cart -and checkout and does not constrain what the Business records (a 0.25-kg +and checkout and does not constrain what the Business records (a 0.25-lb ordering increment does not prevent recording an actual picked weight of -1.45 kg). +1.90 lb). ### Fulfillment @@ -152,8 +152,8 @@ Line items reflect what was purchased at checkout and their current state. When the item is measure-denominated these are step counts — for an `item.quantity_unit` of -`{ "unit": "KGM", "scale": 2, "display_text": "kg" }`, `fulfilled: 50` means -0.50 kg fulfilled. +`{ "unit": "LBR", "scale": 2, "display_text": "lb" }`, `fulfilled: 50` means +0.50 lb fulfilled. **Status Derivation:** @@ -301,14 +301,14 @@ Examples: `refund`, `return`, `credit`, `price_adjustment`, `dispute`, ## Example: goods sold by measure -An order for loose stainless steel fasteners sold by the kilogram -(`item.quantity_unit` -`{ "unit": "KGM", "scale": 2, "display_text": "kg" }`). Quantities are step -counts: the Buyer ordered 1.50 kg (`total: 150`), of which 0.50 kg has shipped -(`fulfilled: 50`), so the line is `partial`. A later return of 0.25 kg is -recorded as an adjustment of `-25` steps. Amounts are priced at $12.99/kg and -rounded once — the line total is `1299 × 1.50 = 1949`, and the return credits -`1299 × 0.25 = 324.75`, rounded to `325`: +An order for bananas sold by the pound (`item.quantity_unit` +`{ "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 }`). +Quantities are step counts: the Buyer ordered 1.50 lb (`total: 150`), of which +0.50 lb has shipped (`fulfilled: 50`), so the line is `partial`. A later +return of 0.25 lb is recorded as an adjustment of `-25` steps. Amounts are +priced at $0.79/lb and rounded once — the line total is `79 × 1.50 = 118.5`, +rounded to `119`, and the return credits `79 × 0.25 = 19.75`, rounded to +`20`: ```json @@ -317,23 +317,23 @@ rounded once — the line total is `1299 × 1.50 = 1949`, and the return credits "version": "{{ ucp_version }}", "capabilities": { "dev.ucp.shopping.order": [{"version": "{{ ucp_version }}"}] } }, - "id": "order_fasteners_1", - "checkout_id": "chk_fasteners_1", - "permalink_url": "https://business.example.com/orders/fasteners1", + "id": "order_bananas_1", + "checkout_id": "chk_bananas_1", + "permalink_url": "https://business.example.com/orders/bananas1", "currency": "USD", "line_items": [ { - "id": "li_fasteners", + "id": "li_bananas", "item": { - "id": "var_fasteners", - "title": "Stainless Steel Fasteners", - "price": 1299, - "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 } + "id": "var_bananas", + "title": "Bananas", + "price": 79, + "quantity_unit": { "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 } }, "quantity": { "original": 150, "total": 150, "fulfilled": 50 }, "totals": [ - { "type": "subtotal", "amount": 1949 }, - { "type": "total", "amount": 1949 } + { "type": "subtotal", "amount": 119 }, + { "type": "total", "amount": 119 } ], "status": "partial" } @@ -344,10 +344,10 @@ rounded once — the line total is `1299 × 1.50 = 1949`, and the return credits "id": "evt_1", "occurred_at": "2026-01-08T10:30:00Z", "type": "shipped", - "line_items": [{ "id": "li_fasteners", "quantity": 50 }], + "line_items": [{ "id": "li_bananas", "quantity": 50 }], "tracking_number": "123456789", "tracking_url": "https://carrier.example/track/123456789", - "description": "0.50 kg shipped" + "description": "0.50 lb shipped" } ] }, @@ -357,14 +357,14 @@ rounded once — the line total is `1299 × 1.50 = 1949`, and the return credits "type": "return", "occurred_at": "2026-01-10T14:30:00Z", "status": "completed", - "line_items": [{ "id": "li_fasteners", "quantity": -25 }], - "totals": [{ "type": "total", "amount": -325 }], - "description": "Returned 0.25 kg" + "line_items": [{ "id": "li_bananas", "quantity": -25 }], + "totals": [{ "type": "total", "amount": -20 }], + "description": "Returned 0.25 lb" } ], "totals": [ - { "type": "subtotal", "amount": 1949 }, - { "type": "total", "amount": 1949 } + { "type": "subtotal", "amount": 119 }, + { "type": "total", "amount": 119 } ] } ``` @@ -378,16 +378,14 @@ records the actual pick and reconciles the difference with an tolerance comparison is involved; the status derivation operates on exact step counts throughout. -A Buyer orders 1.50 kg of the same loose fasteners at $12.99/kg, sold in -quarter-kilogram increments (`quantity` `150`, line total -`1299 × 1.50 = 1948.5`, rounded once to `1949`). The picker weighs out -1.45 kg — an off-increment fact, recorded as-is, because the +A Buyer orders 2.00 lb of the same bananas at $0.79/lb, sold in quarter-pound +increments (`quantity` `200`, line total `79 × 2.00 = 158`). The picker weighs +out 1.90 lb — an off-increment fact, recorded as-is, because the [`increment`](overview.md#ordering-increment) binds Platform ordering, not -Business records. The fulfillment event records the actual `145` steps, and a -`price_adjustment` of `-5` steps reconciles `total` to the actual pick with -its price delta (`1299 × 0.05 = 64.95`, rounded once to `65`). -`fulfilled == total` then holds exactly (`145 == 145`) and the line derives -`fulfilled`: +Business records. The fulfillment event records the actual `190` steps, and a +`price_adjustment` of `-10` steps reconciles `total` to the actual pick with +its price delta (`79 × 0.10 = 7.9`, rounded once to `8`). `fulfilled == total` +then holds exactly (`190 == 190`) and the line derives `fulfilled`: ```json @@ -396,23 +394,23 @@ its price delta (`1299 × 0.05 = 64.95`, rounded once to `65`). "version": "{{ ucp_version }}", "capabilities": { "dev.ucp.shopping.order": [{"version": "{{ ucp_version }}"}] } }, - "id": "order_fasteners_2", - "checkout_id": "chk_fasteners_2", - "permalink_url": "https://business.example.com/orders/fasteners2", + "id": "order_bananas_2", + "checkout_id": "chk_bananas_2", + "permalink_url": "https://business.example.com/orders/bananas2", "currency": "USD", "line_items": [ { - "id": "li_fasteners", + "id": "li_bananas", "item": { - "id": "var_fasteners", - "title": "Stainless Steel Fasteners", - "price": 1299, - "quantity_unit": { "unit": "KGM", "scale": 2, "display_text": "kg", "increment": 25 } + "id": "var_bananas", + "title": "Bananas", + "price": 79, + "quantity_unit": { "unit": "LBR", "scale": 2, "display_text": "lb", "increment": 25 } }, - "quantity": { "original": 150, "total": 145, "fulfilled": 145 }, + "quantity": { "original": 200, "total": 190, "fulfilled": 190 }, "totals": [ - { "type": "subtotal", "amount": 1949 }, - { "type": "total", "amount": 1949 } + { "type": "subtotal", "amount": 158 }, + { "type": "total", "amount": 158 } ], "status": "fulfilled" } @@ -423,8 +421,8 @@ its price delta (`1299 × 0.05 = 64.95`, rounded once to `65`). "id": "evt_1", "occurred_at": "2026-01-12T09:15:00Z", "type": "shipped", - "line_items": [{ "id": "li_fasteners", "quantity": 145 }], - "description": "Picked 1.45 kg" + "line_items": [{ "id": "li_bananas", "quantity": 190 }], + "description": "Picked 1.90 lb" } ] }, @@ -434,14 +432,14 @@ its price delta (`1299 × 0.05 = 64.95`, rounded once to `65`). "type": "price_adjustment", "occurred_at": "2026-01-12T09:15:00Z", "status": "completed", - "line_items": [{ "id": "li_fasteners", "quantity": -5 }], - "totals": [{ "type": "total", "amount": -65 }], - "description": "Adjusted to actual picked weight of 1.45 kg" + "line_items": [{ "id": "li_bananas", "quantity": -10 }], + "totals": [{ "type": "total", "amount": -8 }], + "description": "Adjusted to actual picked weight of 1.90 lb" } ], "totals": [ - { "type": "subtotal", "amount": 1949 }, - { "type": "total", "amount": 1949 } + { "type": "subtotal", "amount": 158 }, + { "type": "total", "amount": 158 } ] } ``` diff --git a/docs/specification/overview.md b/docs/specification/overview.md index bfe7d67a3..1b0e4b670 100644 --- a/docs/specification/overview.md +++ b/docs/specification/overview.md @@ -66,8 +66,8 @@ count plus a declared interpretation, exactly as an `amount` relates to its `currency`. Integer counts keep every total and comparison exact in every language, and UCP therefore defines no rounding tolerances and no epsilon comparisons anywhere in the quantity lifecycle. A fulfilled quantity that -legitimately differs from the ordered quantity — a 1.45 kg pick against a -1.50 kg order — is a commercial fact reconciled through +legitimately differs from the ordered quantity — a 1.90 lb pick against a +2.00 lb order — is a commercial fact reconciled through [adjustments](order.md#adjustments) that move money together with quantity, not a numeric error absorbed by comparison fuzz. @@ -125,7 +125,7 @@ optional positive integer, denominated in steps, whose effective value is the provided value or `1` when omitted. Only the sale basis carries an increment; the bare unit descriptor and the shared measure type do not. It declares the ordering granularity the Business sells in — for example, an item sold by the -kilogram with `scale` `2` and `increment` `25` is sold in 0.25 kg multiples. +pound with `scale` `2` and `increment` `25` is sold in 0.25 lb multiples. `scale` and `increment` play different roles: `scale` bounds what any quantity can express; `increment` shapes what the Platform asks for. The increment is