From 5cdfb591d433527dbadb82cee6ec4e31fc330534 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Thu, 12 Mar 2026 12:52:19 -0700 Subject: [PATCH 1/9] feat: formalize totals contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit formalizes totals as a "receipt-printer" contract: The business is the authoritative source for presented totals — their content and order — because the correct presentation is subject to regional, product, and regulatory requirements that the business is obligated to satisfy. Platforms MUST render all top-level entries in the order provided. Platforms MUST NOT interpret, filter, reorder, aggregate, or apply display logic of their own. Key design decisions: - Open type: closed enum replaced with open string. Well-known values (subtotal, discount, tax, fee, fulfillment, total, items_discount) have implicit effect defaults. Businesses may introduce custom types with an explicit effect field; opens up extensibility for backwards-compatible additions (e.g. gift cards, store credit, etc). - effect field (add/subtract): self-describing sign convention. Well-known types carry implicit defaults; custom types must include it. Platforms resolve sign from effect without hardcoded type knowledge. The total entry has no effect — it is the pre-computed result, not an adjustment. - Repeating types: all types except subtotal and total may appear multiple times. Enables multi-jurisdiction tax (separate federal/state lines), itemized fees (service + recycling + handling), and multiple discounts. - Sub-lines (lines): optional nested breakdown for progressive enhancement. The parent entry is always rendered; sub-lines are supplementary detail platforms SHOULD render when provided. The business controls what must be displayed (top-level) versus what may be optionally surfaced (sub-lines). - Cardinality: exactly one subtotal and one total, schema-enforced via JSON Schema 2020-12 contains/minContains/maxContains. Schema changes: - total.json renamed to total_line.json — base entry type with open type string. Used by line_item, order_line_item, fulfillment_option. - totals.json (new) — constrained array composing total_line.json with required display_text, effect, lines, and cardinality rules. Used by checkout, cart, order. Schema composition in totals.json: The array has two layers of constraints, each using allOf: Item shape (items.allOf): composes the base total_line.json entry with checkout-level requirements — display_text becomes required, and the effect and lines fields are added. This tightening only applies at checkout/cart/order level; line_item and fulfillment_option reference total_line.json directly (no tightening, display_text stays optional). Array cardinality (top-level allOf): uses contains/minContains/maxContains to enforce exactly one subtotal and exactly one total entry. All other types are unconstrained — they may appear zero or more times. This separation means the base entry type (total_line.json) stays simple and unchanged for sub-level consumers, while checkout/cart/order get the full receipt-printer contract through composition. Backward compatibility: - Additive: effect field (optional, defaults for well-known types), lines array (optional), totals.json constrained array type (new file). - Tightening: display_text required at checkout/cart/order level (every existing implementation includes it). Cardinality constraints on subtotal and total (makes implicit contract explicit). - Relaxation: type enum opened to string — all existing values remain valid, schema now additionally accepts custom types. --- docs/specification/cart.md | 6 +- docs/specification/checkout.md | 138 +++++++++++++++++- docs/specification/fulfillment.md | 2 +- docs/specification/order.md | 2 +- source/schemas/shopping/cart.json | 5 +- source/schemas/shopping/checkout.json | 5 +- source/schemas/shopping/order.json | 5 +- .../shopping/types/fulfillment_option.json | 2 +- source/schemas/shopping/types/line_item.json | 2 +- .../shopping/types/order_line_item.json | 2 +- .../types/{total.json => total_line.json} | 17 +-- source/schemas/shopping/types/totals.json | 65 +++++++++ 12 files changed, 217 insertions(+), 34 deletions(-) rename source/schemas/shopping/types/{total.json => total_line.json} (53%) create mode 100644 source/schemas/shopping/types/totals.json diff --git a/docs/specification/cart.md b/docs/specification/cart.md index 590cdb9a..aaab0948 100644 --- a/docs/specification/cart.md +++ b/docs/specification/cart.md @@ -213,7 +213,11 @@ consistent data structures when converting a cart to a checkout session. ### Total -{{ schema_fields('types/total_resp', 'checkout') }} +The same totals contract applies to cart and checkout. See +[Checkout Totals](checkout.md#totals) for the rendering contract, accounting +identity, well-known types, repeating types, and sub-line semantics. + +{{ schema_fields('types/total_line_resp', 'checkout') }} Taxes MAY be included where calculable. Platforms SHOULD assume cart totals are estimates; accurate taxes are computed at checkout. diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index ee286321..42433fd2 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -521,11 +521,143 @@ field or omitting them. {{ extension_schema_fields('capability.json#/$defs/response_schema', 'checkout') }} -### Total +### Total {: #totals } -#### Total +{{ schema_fields('types/total_line_resp', 'checkout') }} -{{ schema_fields('types/total_resp', 'checkout') }} +#### Rendering Contract + +Businesses are the authoritative source for presented totals — their content +and order — because the correct presentation is subject to regional, product, +and regulatory requirements that the business is obligated to satisfy (e.g., +multi-jurisdiction tax itemization, mandatory fee disclosures). + +Platforms MUST render all top-level entries in the order provided: + +```python +for entry in totals: + render_line(entry.display_text, entry.amount, entry.effect or default_effect(entry.type)) +``` + +Platforms MAY render sub-lines as supplementary detail: + +```python +for entry in totals: + render_line(entry.display_text, entry.amount, entry.effect or default_effect(entry.type)) + if entry.lines: + for sub in entry.lines: + render_detail_line(sub.display_text, sub.amount) +``` + +Platforms MUST NOT interpret, filter, reorder, aggregate, or apply display +logic of their own. + +Invariants of `totals[]`: + +* Every entry carries a `display_text` and an `amount`. +* Every entry except `type: "total"` has an `effect`. +* Exactly one `type: "subtotal"` MUST be present. +* Exactly one `type: "total"` MUST be present. + +#### Verification + +Platforms MUST NOT substitute their own computed totals for the business's +values. Platforms MAY verify the provided totals: + +```python +computed = 0 +for entry in totals: + if entry.type == "total": + continue + effect = entry.effect or default_effect(entry.type) + if effect == "add": + computed += entry.amount + elif effect == "subtract": + computed -= entry.amount + +assert computed == totals.find(type == "total").amount +``` + +If the computed sum of entries does not match the `type: "total"` entry, the +platform MUST NOT alter the rendered output — the business's total is +authoritative. Platforms MAY prevent checkout completion or surface the +discrepancy to the buyer. + +#### Well-Known Types + +| Type | Default `effect` | Meaning | +| ----------------- | ---------------- | ---------------------------------------------- | +| `subtotal` | `add` | Sum of line item prices | +| `discount` | `subtract` | Order or line-item level discount | +| `items_discount` | `subtract` | Rollup of line-item discounts | +| `fulfillment` | `add` | Shipping, delivery, or pickup charges | +| `tax` | `add` | Tax charges | +| `fee` | `add` | Fees and surcharges | +| `total` | — | Authoritative grand total (exactly one) | + +Businesses MAY use `type` values beyond the well-known set. Types outside +the well-known set MUST include an explicit `effect` field. If `effect` is +included on a well-known type, it MUST match the default above. + +#### Repeating Types + +All types except `subtotal` and `total` MAY appear multiple times — +for example, multi-jurisdiction tax lines or itemized fees. + +#### Sub-Lines (`lines`) + +Each top-level entry MAY include a `lines` array. Sub-lines share the same +base shape as top-level entries — `display_text` and `amount` — providing an +itemized breakdown under the parent. + +**Invariant:** `sum(lines[].amount)` MUST equal the parent entry's `amount`. + +The business controls what MUST be rendered (top-level entries) versus what +MAY be optionally surfaced (sub-lines). Platforms SHOULD render sub-lines +when provided. + +#### Examples + +**Split tax, itemized at top-level:** + +```json +"totals": [ + { "type": "subtotal", "display_text": "Subtotal", "amount": 5750 }, + { "type": "fulfillment", "display_text": "Shipping", "amount": 899 }, + { "type": "tax", "display_text": "Federal Tax", "amount": 332 }, + { "type": "tax", "display_text": "State Tax", "amount": 465 }, + { "type": "total", "display_text": "Total", "amount": 7446 } +] +``` + +**Collapsed fees with optional breakdown:** + +```json +"totals": [ + { "type": "subtotal", "display_text": "Subtotal", "amount": 4999 }, + { + "type": "fee", "display_text": "Fees", "amount": 549, + "lines": [ + { "display_text": "Service Fee", "amount": 399 }, + { "display_text": "Recycling Fee", "amount": 150 } + ] + }, + { "type": "tax", "display_text": "Tax", "amount": 444 }, + { "type": "total", "display_text": "Total", "amount": 5992 } +] +``` + +**Account credit — custom type with explicit `effect`:** + +```json +"totals": [ + { "type": "subtotal", "display_text": "Subtotal", "amount": 10000 }, + { "type": "tax", "display_text": "Tax", "amount": 800 }, + { "type": "account_credit", "display_text": "Account Credit", "amount": 2500, + "effect": "subtract" }, + { "type": "total", "display_text": "Amount Due", "amount": 8300 } +] +``` ### UCP Response Checkout {: #ucp-response-checkout-schema } diff --git a/docs/specification/fulfillment.md b/docs/specification/fulfillment.md index b511f165..791e996a 100644 --- a/docs/specification/fulfillment.md +++ b/docs/specification/fulfillment.md @@ -90,7 +90,7 @@ method. #### Total -{{ schema_fields('types/total_resp', 'fulfillment') }} +{{ schema_fields('types/total_line_resp', 'fulfillment') }} #### Postal Address diff --git a/docs/specification/order.md b/docs/specification/order.md index 9830dfd4..7941db96 100644 --- a/docs/specification/order.md +++ b/docs/specification/order.md @@ -393,7 +393,7 @@ zero-downtime key rotation procedures. ### Total -{{ schema_fields('types/total_resp', 'order') }} +{{ schema_fields('types/total_line_resp', 'order') }} ### UCP Response Order Schema {: #ucp-response-order-schema } diff --git a/source/schemas/shopping/cart.json b/source/schemas/shopping/cart.json index 746a2087..8ff2751e 100644 --- a/source/schemas/shopping/cart.json +++ b/source/schemas/shopping/cart.json @@ -83,10 +83,7 @@ "ucp_request": "omit" }, "totals": { - "type": "array", - "items": { - "$ref": "types/total.json" - }, + "$ref": "types/totals.json", "description": "Estimated cost breakdown. May be partial if shipping/tax not yet calculable.", "ucp_request": "omit" }, diff --git a/source/schemas/shopping/checkout.json b/source/schemas/shopping/checkout.json index ed2b5a37..02d72553 100644 --- a/source/schemas/shopping/checkout.json +++ b/source/schemas/shopping/checkout.json @@ -82,10 +82,7 @@ } }, "totals": { - "type": "array", - "items": { - "$ref": "types/total.json" - }, + "$ref": "types/totals.json", "description": "Different cart totals.", "ucp_request": "omit" }, diff --git a/source/schemas/shopping/order.json b/source/schemas/shopping/order.json index 80fdd4cf..ad738489 100644 --- a/source/schemas/shopping/order.json +++ b/source/schemas/shopping/order.json @@ -87,10 +87,7 @@ "description": "ISO 4217 currency code. MUST match the currency from the originating checkout session." }, "totals": { - "type": "array", - "items": { - "$ref": "types/total.json" - }, + "$ref": "types/totals.json", "description": "Different totals for the order." } } diff --git a/source/schemas/shopping/types/fulfillment_option.json b/source/schemas/shopping/types/fulfillment_option.json index ad879085..ab46b3dc 100644 --- a/source/schemas/shopping/types/fulfillment_option.json +++ b/source/schemas/shopping/types/fulfillment_option.json @@ -43,7 +43,7 @@ "totals": { "type": "array", "items": { - "$ref": "total.json" + "$ref": "total_line.json" }, "description": "Fulfillment option totals breakdown.", "ucp_request": "omit" diff --git a/source/schemas/shopping/types/line_item.json b/source/schemas/shopping/types/line_item.json index 0eba9ed0..310c8509 100644 --- a/source/schemas/shopping/types/line_item.json +++ b/source/schemas/shopping/types/line_item.json @@ -29,7 +29,7 @@ "totals": { "type": "array", "items": { - "$ref": "total.json" + "$ref": "total_line.json" }, "description": "Line item totals breakdown.", "ucp_request": "omit" diff --git a/source/schemas/shopping/types/order_line_item.json b/source/schemas/shopping/types/order_line_item.json index 758e6b63..3144744b 100644 --- a/source/schemas/shopping/types/order_line_item.json +++ b/source/schemas/shopping/types/order_line_item.json @@ -39,7 +39,7 @@ "totals": { "type": "array", "items": { - "$ref": "total.json" + "$ref": "total_line.json" }, "description": "Line item totals breakdown." }, diff --git a/source/schemas/shopping/types/total.json b/source/schemas/shopping/types/total_line.json similarity index 53% rename from source/schemas/shopping/types/total.json rename to source/schemas/shopping/types/total_line.json index cd43d009..9cd47d57 100644 --- a/source/schemas/shopping/types/total.json +++ b/source/schemas/shopping/types/total_line.json @@ -1,7 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ucp.dev/schemas/shopping/types/total.json", - "title": "Total", + "$id": "https://ucp.dev/schemas/shopping/types/total_line.json", + "title": "Total Line", + "description": "A cost breakdown entry with a category, amount, and optional display text.", "type": "object", "required": [ "type", @@ -10,16 +11,7 @@ "properties": { "type": { "type": "string", - "enum": [ - "items_discount", - "subtotal", - "discount", - "fulfillment", - "tax", - "fee", - "total" - ], - "description": "Type of total categorization.", + "description": "Cost category. Well-known values: subtotal, items_discount, discount, fulfillment, tax, fee, total. Businesses MAY use additional values.", "ucp_request": "omit" }, "display_text": { @@ -29,7 +21,6 @@ }, "amount": { "$ref": "amount.json", - "description": "If type == total, sums subtotal - discount + fulfillment + tax + fee. Should be >= 0. Amount in ISO 4217 minor units.", "ucp_request": "omit" } } diff --git a/source/schemas/shopping/types/totals.json b/source/schemas/shopping/types/totals.json new file mode 100644 index 00000000..36b70313 --- /dev/null +++ b/source/schemas/shopping/types/totals.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/totals.json", + "title": "Totals", + "description": "Pricing breakdown provided by the business. MUST contain exactly one subtotal and one total entry. Detail types (tax, fee, discount, fulfillment) may appear multiple times for itemization. Platforms MUST render all entries in order using display_text and amount.", + "type": "array", + "items": { + "allOf": [ + { + "$ref": "total_line.json" + }, + { + "type": "object", + "required": [ + "display_text" + ], + "properties": { + "effect": { + "type": "string", + "enum": [ + "add", + "subtract" + ], + "description": "Sign convention for this entry. Well-known types have implicit defaults (see specification); unknown types MUST include this field.", + "ucp_request": "omit" + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "required": [ + "display_text", + "amount" + ], + "properties": { + "display_text": { + "type": "string", + "description": "Human-readable label for this sub-line." + }, + "amount": { + "$ref": "amount.json" + } + }, + "description": "Sub-line entry. Additional metadata MAY be included." + }, + "description": "Optional itemized breakdown. The parent entry is always rendered; lines are supplementary. Sum of line amounts MUST equal the parent entry amount.", + "ucp_request": "omit" + } + } + } + ] + }, + "allOf": [ + { + "contains": { "properties": { "type": { "const": "subtotal" } }, "required": ["type"] }, + "minContains": 1, + "maxContains": 1 + }, + { + "contains": { "properties": { "type": { "const": "total" } }, "required": ["type"] }, + "minContains": 1, + "maxContains": 1 + } + ] +} From 6fd2c53970dfad0f865b63da6766b2b813681c7f Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Sun, 15 Mar 2026 22:43:53 -0700 Subject: [PATCH 2/9] use signed amounts in totals, drop effect field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the effect field (add/subtract) with signed integers on totals entries. Discounts and credits are negative; charges are positive. The sign IS the direction — no separate field needed. --- docs/specification/checkout.md | 72 ++++++++----------- .../schemas/shopping/types/signed_amount.json | 7 ++ source/schemas/shopping/types/total_line.json | 2 +- source/schemas/shopping/types/totals.json | 11 +-- 4 files changed, 40 insertions(+), 52 deletions(-) create mode 100644 source/schemas/shopping/types/signed_amount.json diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index 42433fd2..e56e53b6 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -536,14 +536,14 @@ Platforms MUST render all top-level entries in the order provided: ```python for entry in totals: - render_line(entry.display_text, entry.amount, entry.effect or default_effect(entry.type)) + render_line(entry.display_text, entry.amount) ``` Platforms MAY render sub-lines as supplementary detail: ```python for entry in totals: - render_line(entry.display_text, entry.amount, entry.effect or default_effect(entry.type)) + render_line(entry.display_text, entry.amount) if entry.lines: for sub in entry.lines: render_detail_line(sub.display_text, sub.amount) @@ -555,7 +555,8 @@ logic of their own. Invariants of `totals[]`: * Every entry carries a `display_text` and an `amount`. -* Every entry except `type: "total"` has an `effect`. +* Amounts are signed integers — negative values are subtractive (e.g., + discounts), positive values are additive. The sign IS the direction. * Exactly one `type: "subtotal"` MUST be present. * Exactly one `type: "total"` MUST be present. @@ -565,39 +566,28 @@ Platforms MUST NOT substitute their own computed totals for the business's values. Platforms MAY verify the provided totals: ```python -computed = 0 -for entry in totals: - if entry.type == "total": - continue - effect = entry.effect or default_effect(entry.type) - if effect == "add": - computed += entry.amount - elif effect == "subtract": - computed -= entry.amount - -assert computed == totals.find(type == "total").amount +assert sum(e.amount for e in totals if e.type != "total") == total_entry.amount ``` -If the computed sum of entries does not match the `type: "total"` entry, the -platform MUST NOT alter the rendered output — the business's total is -authoritative. Platforms MAY prevent checkout completion or surface the -discrepancy to the buyer. +If the computed sum does not match the `type: "total"` entry, the platform +MUST NOT alter the rendered output — the business's total is authoritative. +Platforms MAY prevent checkout completion or surface the discrepancy to the +buyer. #### Well-Known Types -| Type | Default `effect` | Meaning | -| ----------------- | ---------------- | ---------------------------------------------- | -| `subtotal` | `add` | Sum of line item prices | -| `discount` | `subtract` | Order or line-item level discount | -| `items_discount` | `subtract` | Rollup of line-item discounts | -| `fulfillment` | `add` | Shipping, delivery, or pickup charges | -| `tax` | `add` | Tax charges | -| `fee` | `add` | Fees and surcharges | -| `total` | — | Authoritative grand total (exactly one) | +| Type | Sign | Meaning | +| ----------------- | ---------- | ---------------------------------------------- | +| `subtotal` | + | Sum of line item prices | +| `discount` | − | Order or line-item level discount | +| `items_discount` | − | Rollup of line-item discounts | +| `fulfillment` | + | Shipping, delivery, or pickup charges | +| `tax` | + | Tax charges | +| `fee` | + | Fees and surcharges | +| `total` | = | Authoritative grand total (exactly one) | -Businesses MAY use `type` values beyond the well-known set. Types outside -the well-known set MUST include an explicit `effect` field. If `effect` is -included on a well-known type, it MUST match the default above. +The `type` field is an open string. Businesses MAY use values beyond the +well-known set — the sign on the amount is self-describing. #### Repeating Types @@ -622,11 +612,11 @@ when provided. ```json "totals": [ - { "type": "subtotal", "display_text": "Subtotal", "amount": 5750 }, - { "type": "fulfillment", "display_text": "Shipping", "amount": 899 }, - { "type": "tax", "display_text": "Federal Tax", "amount": 332 }, - { "type": "tax", "display_text": "State Tax", "amount": 465 }, - { "type": "total", "display_text": "Total", "amount": 7446 } + { "type": "subtotal", "display_text": "Subtotal", "amount": 5750 }, + { "type": "fulfillment", "display_text": "Shipping", "amount": 899 }, + { "type": "tax", "display_text": "Federal Tax", "amount": 332 }, + { "type": "tax", "display_text": "State Tax", "amount": 465 }, + { "type": "total", "display_text": "Total", "amount": 7446 } ] ``` @@ -647,15 +637,15 @@ when provided. ] ``` -**Account credit — custom type with explicit `effect`:** +**Discount and account credit — negative amounts:** ```json "totals": [ - { "type": "subtotal", "display_text": "Subtotal", "amount": 10000 }, - { "type": "tax", "display_text": "Tax", "amount": 800 }, - { "type": "account_credit", "display_text": "Account Credit", "amount": 2500, - "effect": "subtract" }, - { "type": "total", "display_text": "Amount Due", "amount": 8300 } + { "type": "subtotal", "display_text": "Subtotal", "amount": 10000 }, + { "type": "discount", "display_text": "Summer Sale", "amount": -1500 }, + { "type": "tax", "display_text": "Tax", "amount": 680 }, + { "type": "account_credit", "display_text": "Account Credit", "amount": -2500 }, + { "type": "total", "display_text": "Amount Due", "amount": 6680 } ] ``` diff --git a/source/schemas/shopping/types/signed_amount.json b/source/schemas/shopping/types/signed_amount.json new file mode 100644 index 00000000..b68d5778 --- /dev/null +++ b/source/schemas/shopping/types/signed_amount.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/signed_amount.json", + "title": "Signed Amount", + "description": "Monetary amount in the currency's minor unit as defined by ISO 4217. Refer to the currency's exponent to determine minor-to-major ratio (e.g., 2 for USD, 0 for JPY, 3 for KWD). May be negative — the sign is intrinsic to the value (e.g., discounts are negative, charges are positive).", + "type": "integer" +} diff --git a/source/schemas/shopping/types/total_line.json b/source/schemas/shopping/types/total_line.json index 9cd47d57..ffb87a2b 100644 --- a/source/schemas/shopping/types/total_line.json +++ b/source/schemas/shopping/types/total_line.json @@ -20,7 +20,7 @@ "ucp_request": "omit" }, "amount": { - "$ref": "amount.json", + "$ref": "signed_amount.json", "ucp_request": "omit" } } diff --git a/source/schemas/shopping/types/totals.json b/source/schemas/shopping/types/totals.json index 36b70313..5adde206 100644 --- a/source/schemas/shopping/types/totals.json +++ b/source/schemas/shopping/types/totals.json @@ -15,15 +15,6 @@ "display_text" ], "properties": { - "effect": { - "type": "string", - "enum": [ - "add", - "subtract" - ], - "description": "Sign convention for this entry. Well-known types have implicit defaults (see specification); unknown types MUST include this field.", - "ucp_request": "omit" - }, "lines": { "type": "array", "items": { @@ -38,7 +29,7 @@ "description": "Human-readable label for this sub-line." }, "amount": { - "$ref": "amount.json" + "$ref": "signed_amount.json" } }, "description": "Sub-line entry. Additional metadata MAY be included." From aba594371a648e572e0908bcc53ea0bfd8cfac04 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Sun, 15 Mar 2026 22:47:08 -0700 Subject: [PATCH 3/9] enforce sign convention for well-known totals types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add if/then constraints on the totals array items schema to enforce sign at validation time: discount, items_discount → must be negative (exclusiveMaximum: 0) subtotal, fulfillment, tax, fee → must be non-negative (minimum: 0) total → unconstrained unknown types → unconstrained (sign is self-describing) A discount with a positive amount or a fee with a negative amount is now a schema validation error — caught before rendering, not at runtime via sum-equals-total. --- docs/specification/checkout.md | 7 +++++-- source/schemas/shopping/types/totals.json | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index e56e53b6..d4cbcb78 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -586,8 +586,11 @@ buyer. | `fee` | + | Fees and surcharges | | `total` | = | Authoritative grand total (exactly one) | -The `type` field is an open string. Businesses MAY use values beyond the -well-known set — the sign on the amount is self-describing. +The sign convention for well-known types is schema-enforced: subtractive +types (discount, items_discount) MUST have negative amounts; additive types +(subtotal, fulfillment, tax, fee) MUST have non-negative amounts. The `type` +field is an open string — businesses MAY use values beyond the well-known +set, where the sign on the amount is self-describing. #### Repeating Types diff --git a/source/schemas/shopping/types/totals.json b/source/schemas/shopping/types/totals.json index 5adde206..166f10ad 100644 --- a/source/schemas/shopping/types/totals.json +++ b/source/schemas/shopping/types/totals.json @@ -38,6 +38,14 @@ "ucp_request": "omit" } } + }, + { + "if": { "properties": { "type": { "enum": ["discount", "items_discount"] } }, "required": ["type"] }, + "then": { "properties": { "amount": { "exclusiveMaximum": 0 } } } + }, + { + "if": { "properties": { "type": { "enum": ["subtotal", "fulfillment", "tax", "fee"] } }, "required": ["type"] }, + "then": { "properties": { "amount": { "minimum": 0 } } } } ] }, From f64c7b3846b00085bb0d835748bc24d3fbf09faf Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Mon, 16 Mar 2026 12:49:41 -0700 Subject: [PATCH 4/9] revert total.json rename, preserve $id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert total.json → total_line.json rename to avoid breaking the public $id contract. The canonical URI https://ucp.dev/schemas/shopping/types/total.json is unchanged. total.json remains the base entry type. The new totals.json (constrained array) references it via $ref. All consumer schemas (line_item, order_line_item, fulfillment_option) and doc macros restored to original references. --- docs/specification/cart.md | 2 +- docs/specification/checkout.md | 2 +- docs/specification/fulfillment.md | 2 +- docs/specification/order.md | 2 +- source/schemas/shopping/types/fulfillment_option.json | 2 +- source/schemas/shopping/types/line_item.json | 2 +- source/schemas/shopping/types/order_line_item.json | 2 +- source/schemas/shopping/types/{total_line.json => total.json} | 4 ++-- source/schemas/shopping/types/totals.json | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) rename source/schemas/shopping/types/{total_line.json => total.json} (89%) diff --git a/docs/specification/cart.md b/docs/specification/cart.md index 94a8c8f5..08748a40 100644 --- a/docs/specification/cart.md +++ b/docs/specification/cart.md @@ -226,7 +226,7 @@ The same totals contract applies to cart and checkout. See [Checkout Totals](checkout.md#totals) for the rendering contract, accounting identity, well-known types, repeating types, and sub-line semantics. -{{ schema_fields('types/total_line_resp', 'checkout') }} +{{ schema_fields('types/total_resp', 'checkout') }} Taxes MAY be included where calculable. Platforms SHOULD assume cart totals are estimates; accurate taxes are computed at checkout. diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index 5123e2c6..32d51fff 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -612,7 +612,7 @@ field or omitting them. ### Total {: #totals } -{{ schema_fields('types/total_line_resp', 'checkout') }} +{{ schema_fields('types/total_resp', 'checkout') }} #### Rendering Contract diff --git a/docs/specification/fulfillment.md b/docs/specification/fulfillment.md index 791e996a..b511f165 100644 --- a/docs/specification/fulfillment.md +++ b/docs/specification/fulfillment.md @@ -90,7 +90,7 @@ method. #### Total -{{ schema_fields('types/total_line_resp', 'fulfillment') }} +{{ schema_fields('types/total_resp', 'fulfillment') }} #### Postal Address diff --git a/docs/specification/order.md b/docs/specification/order.md index 7941db96..9830dfd4 100644 --- a/docs/specification/order.md +++ b/docs/specification/order.md @@ -393,7 +393,7 @@ zero-downtime key rotation procedures. ### Total -{{ schema_fields('types/total_line_resp', 'order') }} +{{ schema_fields('types/total_resp', 'order') }} ### UCP Response Order Schema {: #ucp-response-order-schema } diff --git a/source/schemas/shopping/types/fulfillment_option.json b/source/schemas/shopping/types/fulfillment_option.json index ab46b3dc..ad879085 100644 --- a/source/schemas/shopping/types/fulfillment_option.json +++ b/source/schemas/shopping/types/fulfillment_option.json @@ -43,7 +43,7 @@ "totals": { "type": "array", "items": { - "$ref": "total_line.json" + "$ref": "total.json" }, "description": "Fulfillment option totals breakdown.", "ucp_request": "omit" diff --git a/source/schemas/shopping/types/line_item.json b/source/schemas/shopping/types/line_item.json index 310c8509..0eba9ed0 100644 --- a/source/schemas/shopping/types/line_item.json +++ b/source/schemas/shopping/types/line_item.json @@ -29,7 +29,7 @@ "totals": { "type": "array", "items": { - "$ref": "total_line.json" + "$ref": "total.json" }, "description": "Line item totals breakdown.", "ucp_request": "omit" diff --git a/source/schemas/shopping/types/order_line_item.json b/source/schemas/shopping/types/order_line_item.json index 3144744b..758e6b63 100644 --- a/source/schemas/shopping/types/order_line_item.json +++ b/source/schemas/shopping/types/order_line_item.json @@ -39,7 +39,7 @@ "totals": { "type": "array", "items": { - "$ref": "total_line.json" + "$ref": "total.json" }, "description": "Line item totals breakdown." }, diff --git a/source/schemas/shopping/types/total_line.json b/source/schemas/shopping/types/total.json similarity index 89% rename from source/schemas/shopping/types/total_line.json rename to source/schemas/shopping/types/total.json index ffb87a2b..0e0477f3 100644 --- a/source/schemas/shopping/types/total_line.json +++ b/source/schemas/shopping/types/total.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ucp.dev/schemas/shopping/types/total_line.json", - "title": "Total Line", + "$id": "https://ucp.dev/schemas/shopping/types/total.json", + "title": "Total", "description": "A cost breakdown entry with a category, amount, and optional display text.", "type": "object", "required": [ diff --git a/source/schemas/shopping/types/totals.json b/source/schemas/shopping/types/totals.json index 166f10ad..7dcb4fe8 100644 --- a/source/schemas/shopping/types/totals.json +++ b/source/schemas/shopping/types/totals.json @@ -7,7 +7,7 @@ "items": { "allOf": [ { - "$ref": "total_line.json" + "$ref": "total.json" }, { "type": "object", From 9a992041b8718e245dd13e6365f895a7b0cf155b Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Mon, 16 Mar 2026 12:52:19 -0700 Subject: [PATCH 5/9] scope signed amounts to checkout-level totals only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move signed_amount.json ref from total.json (base type) into totals.json (checkout/cart/order array composition). The base type reverts to unsigned amount.json. Line item, order line item, and fulfillment option totals inherit the base type — their amounts stay non-negative. Signed amounts, sign enforcement (if/then), and display_text required are all co-located on totals.json where the receipt-printer contract lives. --- source/schemas/shopping/types/total.json | 2 +- source/schemas/shopping/types/totals.json | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/source/schemas/shopping/types/total.json b/source/schemas/shopping/types/total.json index 0e0477f3..6f488d9e 100644 --- a/source/schemas/shopping/types/total.json +++ b/source/schemas/shopping/types/total.json @@ -20,7 +20,7 @@ "ucp_request": "omit" }, "amount": { - "$ref": "signed_amount.json", + "$ref": "amount.json", "ucp_request": "omit" } } diff --git a/source/schemas/shopping/types/totals.json b/source/schemas/shopping/types/totals.json index 7dcb4fe8..8ed2ca42 100644 --- a/source/schemas/shopping/types/totals.json +++ b/source/schemas/shopping/types/totals.json @@ -15,6 +15,9 @@ "display_text" ], "properties": { + "amount": { + "$ref": "signed_amount.json" + }, "lines": { "type": "array", "items": { From 908556d74710617414af6368c082da1947c7aee1 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Mon, 16 Mar 2026 13:31:36 -0700 Subject: [PATCH 6/9] display_text optional for well-known & required for other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Well-known totals types (subtotal, tax, fee, etc.) have default display labels documented in the spec. Businesses SHOULD provide display_text to override, but omitting it on well-known types is valid — platforms fall back to the default label. Unknown types MUST include display_text (schema-enforced via if/then). The platform cannot infer a label for a type it has never seen. This preserves backward compatibility with existing examples that omit display_text on well-known types while enforcing labels on the custom types (fees, account credits) that motivated the totals contract work. --- docs/specification/checkout.md | 38 +++++++++++++---------- source/schemas/shopping/types/totals.json | 12 +++++-- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index 32d51fff..dec3c5c9 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -643,7 +643,9 @@ logic of their own. Invariants of `totals[]`: -* Every entry carries a `display_text` and an `amount`. +* Every entry carries a `type` and an `amount`. Platforms SHOULD use + `display_text` when provided. Well-known types have default display labels + as fallback (see table below); unknown types MUST include `display_text`. * Amounts are signed integers — negative values are subtractive (e.g., discounts), positive values are additive. The sign IS the direction. * Exactly one `type: "subtotal"` MUST be present. @@ -665,21 +667,25 @@ buyer. #### Well-Known Types -| Type | Sign | Meaning | -| ----------------- | ---------- | ---------------------------------------------- | -| `subtotal` | + | Sum of line item prices | -| `discount` | − | Order or line-item level discount | -| `items_discount` | − | Rollup of line-item discounts | -| `fulfillment` | + | Shipping, delivery, or pickup charges | -| `tax` | + | Tax charges | -| `fee` | + | Fees and surcharges | -| `total` | = | Authoritative grand total (exactly one) | - -The sign convention for well-known types is schema-enforced: subtractive -types (discount, items_discount) MUST have negative amounts; additive types -(subtotal, fulfillment, tax, fee) MUST have non-negative amounts. The `type` -field is an open string — businesses MAY use values beyond the well-known -set, where the sign on the amount is self-describing. +| Type | Sign | Default label | Meaning | +| ----------------- | ---- | ---------------- | ----------------------------------------- | +| `subtotal` | + | Subtotal | Sum of line item prices | +| `discount` | − | Discount | Order or line-item level discount | +| `items_discount` | − | Item Discounts | Rollup of line-item discounts | +| `fulfillment` | + | Shipping | Shipping, delivery, or pickup charges | +| `tax` | + | Tax | Tax charges | +| `fee` | + | Fee | Fees and surcharges | +| `total` | = | Total | Authoritative grand total (exactly one) | + +When `display_text` is provided, platforms MUST use it. When omitted on a +well-known type, platforms SHOULD use the default label above. The sign +convention for well-known types is schema-enforced: subtractive types +(discount, items_discount) MUST have negative amounts; additive types +(subtotal, fulfillment, tax, fee) MUST have non-negative amounts. + +The `type` field is an open string — businesses MAY use values beyond the +well-known set. Unknown types MUST include `display_text` (schema-enforced) +and the sign on the amount is self-describing. #### Repeating Types diff --git a/source/schemas/shopping/types/totals.json b/source/schemas/shopping/types/totals.json index 8ed2ca42..71a963b1 100644 --- a/source/schemas/shopping/types/totals.json +++ b/source/schemas/shopping/types/totals.json @@ -11,9 +11,6 @@ }, { "type": "object", - "required": [ - "display_text" - ], "properties": { "amount": { "$ref": "signed_amount.json" @@ -49,6 +46,15 @@ { "if": { "properties": { "type": { "enum": ["subtotal", "fulfillment", "tax", "fee"] } }, "required": ["type"] }, "then": { "properties": { "amount": { "minimum": 0 } } } + }, + { + "if": { + "properties": { + "type": { "not": { "enum": ["subtotal", "items_discount", "discount", "fulfillment", "tax", "fee", "total"] } } + }, + "required": ["type"] + }, + "then": { "required": ["display_text"] } } ] }, From f3a8167a3d585c346495c1b7c8fb56c1cb1dc7cb Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Mon, 16 Mar 2026 14:53:00 -0700 Subject: [PATCH 7/9] MUST NOT autonomously complete on totals mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten verification contract for agentic flows. If sum of entries does not match the total, platforms must not auto-complete — reject or escalate to buyer review via continue_url. --- docs/specification/checkout.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index dec3c5c9..e5c21161 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -661,9 +661,10 @@ assert sum(e.amount for e in totals if e.type != "total") == total_entry.amount ``` If the computed sum does not match the `type: "total"` entry, the platform -MUST NOT alter the rendered output — the business's total is authoritative. -Platforms MAY prevent checkout completion or surface the discrepancy to the -buyer. +MUST NOT alter the rendered output — the business's presented totals are +authoritative for display. However, platforms MUST NOT autonomously complete +a checkout with mismatched totals. Platforms SHOULD reject the checkout or +escalate and ask for buyer review via `continue_url`. #### Well-Known Types From 33c410abc68912f08691ff5ff055664006a82587 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Mon, 16 Mar 2026 21:41:39 -0700 Subject: [PATCH 8/9] update discount amounts to negative values in totals Update discount.md examples to use negative amounts for discount and items_discount totals entries, consistent with the signed totals contract. --- docs/specification/discount.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/specification/discount.md b/docs/specification/discount.md index 8ee62020..85263e5a 100644 --- a/docs/specification/discount.md +++ b/docs/specification/discount.md @@ -274,7 +274,7 @@ stacking and allocation details: }, "totals": [ {"type": "subtotal", "display_text": "Subtotal", "amount": 5000}, - {"type": "items_discount", "display_text": "Discounts", "amount": 250}, + {"type": "items_discount", "display_text": "Discounts", "amount": -250}, {"type": "total", "display_text": "Total", "amount": 4750} ] } @@ -356,7 +356,7 @@ proceeding to checkout. }, "totals": [ {"type": "subtotal", "amount": 4000}, - {"type": "items_discount", "amount": 800}, + {"type": "items_discount", "amount": -800}, {"type": "total", "amount": 3200} ] } @@ -378,7 +378,7 @@ proceeding to checkout. "currency": "USD", "totals": [ {"type": "subtotal", "display_text": "Subtotal", "amount": 4000}, - {"type": "items_discount", "display_text": "Item Discounts", "amount": 800}, + {"type": "items_discount", "display_text": "Item Discounts", "amount": -800}, {"type": "total", "display_text": "Estimated Total", "amount": 3200} ] } @@ -415,7 +415,7 @@ to the order as a whole and uses `type: "discount"` in totals. }, "totals": [ {"type": "subtotal", "display_text": "Subtotal", "amount": 5000}, - {"type": "discount", "display_text": "Order Discount", "amount": 1000}, + {"type": "discount", "display_text": "Order Discount", "amount": -1000}, {"type": "total", "display_text": "Total", "amount": 4000} ] } @@ -451,7 +451,7 @@ to line items, and an automatic shipping discount at the order level. }, "totals": [ {"type": "subtotal", "amount": 4000}, - {"type": "items_discount", "amount": 800}, + {"type": "items_discount", "amount": -800}, {"type": "total", "amount": 3200} ] } @@ -476,8 +476,8 @@ to line items, and an automatic shipping discount at the order level. }, "totals": [ {"type": "subtotal", "display_text": "Subtotal", "amount": 4000}, - {"type": "items_discount", "display_text": "Item Discounts", "amount": 800}, - {"type": "discount", "display_text": "Order Discounts", "amount": 599}, + {"type": "items_discount", "display_text": "Item Discounts", "amount": -800}, + {"type": "discount", "display_text": "Order Discounts", "amount": -599}, {"type": "fulfillment", "display_text": "Shipping", "amount": 0}, {"type": "total", "display_text": "Total", "amount": 2601} ] @@ -516,7 +516,7 @@ but not in `discounts.applied`. }, "totals": [ {"type": "subtotal", "display_text": "Subtotal", "amount": 5000}, - {"type": "discount", "display_text": "Order Discount", "amount": 1000}, + {"type": "discount", "display_text": "Order Discount", "amount": -1000}, {"type": "total", "display_text": "Total", "amount": 4000} ], "messages": [ @@ -547,7 +547,7 @@ Multiple discounts applied with full allocation breakdown: }, "totals": [ {"type": "subtotal", "amount": 6000}, - {"type": "items_discount", "amount": 1500}, + {"type": "items_discount", "amount": -1500}, {"type": "total", "amount": 4500} ] }, @@ -559,7 +559,7 @@ Multiple discounts applied with full allocation breakdown: }, "totals": [ {"type": "subtotal", "amount": 4000}, - {"type": "items_discount", "amount": 1000}, + {"type": "items_discount", "amount": -1000}, {"type": "total", "amount": 3000} ] } @@ -593,7 +593,7 @@ Multiple discounts applied with full allocation breakdown: }, "totals": [ {"type": "subtotal", "display_text": "Subtotal", "amount": 10000}, - {"type": "items_discount", "display_text": "Item Discounts", "amount": 2500}, + {"type": "items_discount", "display_text": "Item Discounts", "amount": -2500}, {"type": "total", "display_text": "Total", "amount": 7500} ] } From 29d949e2f2e628dfe2fd30692064189bb6b458fe Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Mon, 16 Mar 2026 22:51:41 -0700 Subject: [PATCH 9/9] relationship between discount extension and totals Clarify that applied[].amount describes the magnitude of the discount (positive); the corresponding totals entry represents its signed effect on the receipt (negative). --- docs/specification/discount.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/specification/discount.md b/docs/specification/discount.md index 85263e5a..f8f7c69f 100644 --- a/docs/specification/discount.md +++ b/docs/specification/discount.md @@ -80,6 +80,9 @@ When this capability is active, cart and/or checkout are extended with a ## Allocation Details The `applied` array explains how discounts were calculated and distributed. +The `applied[].amount` describes the magnitude of the applied discount (always +positive); the corresponding `totals[]` entry amount represents its signed +effect on the receipt (negative for discounts). ### Allocation Method