Skip to content

feat!: define sale-basis steps for quantities - #653

Open
igrigorik wants to merge 10 commits into
mainfrom
feat/sale-basis
Open

feat!: define sale-basis steps for quantities#653
igrigorik wants to merge 10 commits into
mainfrom
feat/sale-basis

Conversation

@igrigorik

Copy link
Copy Markdown
Contributor

Context: #597. This PR drafts alternate solution shape...


UCP's integer quantity has no denominator. It can represent two bottles, but not 1.50 kg of loose goods while preserving exact fulfillment, adjustment, and return arithmetic. This PR keeps quantity as an integer count of steps and adds quantity_unit to define their denomination and granularity. One step is 10^-scale of unit. The (unit, effective scale) pair is the unit descriptor's machine identity; it neither identifies the purchasable item nor exhaustively describes one sale unit. Omitting quantity_unit from authoritative data preserves the existing each basis.

  • Included: goods whose amount can be fulfilled, short-filled, adjusted, or returned; countable goods; packaged variants; transaction pricing and order lifecycle behavior.
  • Deferred: Buyer-provided dimensions (see below), explicit step restrictions beyond declared scale, multi-unit offers per variant, and B2B configurators with different pricing contracts.

This paves the way for...

1. Each or fixed package

quantity counts purchasable variants. Existing countable-goods payloads are unchanged:

{
  "item": { "id": "var_bottle" },
  "quantity": 2
}

2. Variable amount

When the measured amount itself participates in fulfillment and returns, quantity_unit denominates quantity. An authoritative USD transaction line for loose fasteners sold in hundredth-of-a-kilogram steps carries the per-kilogram price, sale basis, and quantity together:

{
  "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 }
  ]
}

Here quantity: 150 means 1.50 kg and item.price: 1299 is 1299 minor units per whole kilogram. The Business computes 1299 × 150 × 10^-2 = 1948.5 and rounds once to the authoritative line total of 1949 ($19.49).

The same steps flow through the order lifecycle:

{
  "quantity": {
    "original": 150,
    "total": 150,
    "fulfilled": 50
  },
  "status": "partial"
}

Here fulfilled: 50 means 0.50 kg and an adjustment of -25 means a 0.25 kg return—all in one inherited unit.

Key design decisions

  1. Integer steps, not decimals. Quantity participates in equality, accumulation, and signed adjustments. Integer steps provide the same exactness UCP requires for currency minor units.
  2. The unit-descriptor machine identity is (unit, effective scale). scale defaults to 0; required display_text is presentation-only. This identity defines the denomination, not the complete purchasable item. C62 represents each and cannot use a nonzero scale.
  3. No UCP unit ontology. Businesses use UN/CEFACT Rec20. A Business MAY use a custom identifier when no code fits, but MUST use it consistently. Platforms treat unknown identifiers as opaque and render display_text. Package codes are excluded because packages count as each.
  4. One inherited sale basis. Catalog advertises it; Cart and Checkout transact in it; Order applies it to lifecycle arithmetic. Referenced records inherit rather than redeclare the unit.
  5. Request omission makes no assertion. Platforms MAY assert (unit, effective scale). Businesses MUST echo non-each descriptors and surface mismatches; neither side silently converts quantities.
  6. Price is per whole unit. Per-step pricing could require fractional currency minor units. Other characteristics of a sale unit may affect the Business-quoted price without changing this denominator. Businesses compute price × quantity × 10^-scale, round once at the line, and return authoritative totals.

Compatibility and migration

Backward compatible:

  • quantity remains an integer everywhere.
  • quantity_unit is optional; omission retains the existing each wire representation.
  • Existing quantity, fulfillment, expectation, and adjustment field types do not change.

Breaking:

This is a Core Protocol feat! change because existing unit_price producers must migrate measure and reference from decimal/free-form measurements to positive integer values plus the shared descriptor (unit, display_text, optional scale). Their units and currencies must match, and zero or negative comparator measures are invalid. This removes a second unit vocabulary and numeric representation rather than preserving parallel paths.


Checklist

  • Core Protocol: Changes to the base communication layer, global context, or breaking refactors.
  • Capability: New schemas (Discovery, Cart, etc.) or extensions.
  • Documentation: Updates to README, or documentations regarding schema or capabilities.
  • I have followed the Contributing Guide
  • I have updated the documentation (if applicable).
  • My changes pass all local linting and formatting checks.

   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.
   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.
@igrigorik
igrigorik requested a review from jingyli July 30, 2026 21:12
@igrigorik igrigorik self-assigned this Jul 30, 2026
@igrigorik igrigorik added the TC review Ready for TC review label Jul 30, 2026
@amithanda

Copy link
Copy Markdown
Contributor

Thanks for putting this together, @igrigorik! This PR seems heavily aligned with one of the design options we discussed in #597, and it completely solves the arithmetic coupling and nesting problems of an anyOf quantity object.

1. What I think is aligned with the #597 design option:

  • Single Unit Declaration on item / variant: Instead of embedding { value, unit } inside every quantity field, the selling unit is declared once on item.quantity_unit (and variant.quantity_unit).
  • Inheritance Across the Order Lifecycle: All dependent records (order_line_item.quantity.{original,total,fulfilled}, fulfillment_event, adjustment) carry plain scalar quantities and inherit the line item's unit. Two records on the same line can never disagree about units.
  • Unit Pricing (item.price): price is always quoted per whole selling unit (e.g., per 1 kg or per 1 lb), not per step.
  • Defaulting to each: An absent descriptor cleanly defaults to countable items (each), requiring zero migration for existing countable-only producers.

2. The One Key Difference: Approach A vs. Approach B

Where this PR diverges from that proposal is in how numeric quantities and step granularity are represented on the wire:

  • PR# 597 Proposal (Approach A: Decimal + Arbitrary Step): quantity is a floating-point number (1.5), and the unit descriptor declares an explicit decimal increment via step: 0.25.
  • This PR (Approach B: Integer Step Count + Scale): quantity is an integer step count (150), and the unit descriptor declares a power-of-ten scale: 2 (10^-2, where 1 step = 0.01).

3. I am leaning towards Approach A (number + step), here's why:

While integer minor-unit math (scale: 2150) works beautifully for currency amounts (price.amount), applying that same pattern to physical quantities introduces significant developer-experience risks and UI limitations that Approach A avoids.

A. The "Scale Misread Hazard" vs. WYSIWYG Readability

In e-commerce, physical quantities are natively decimal (1.5 lb, 0.75 kg, 2.5 yd).

// Approach A: WYSIWYG (What You See Is What You Get)
{
  "item": { "id": "sku_apples", "sold_by": { "unit": "lb", "step": 0.25 } },
  "quantity": 1.5
}

// Approach B: Indirected (Requires mental/programmatic division by 10^scale)
{
  "item": { "id": "sku_apples", "quantity_unit": { "unit": "LBR", "scale": 2, "display_text": "lb" } },
  "quantity": 150
}
  • In Approach A (number): Any frontend developer, backend service, or LLM shopping agent reading "quantity": 1.5 immediately understands 1.5 lb without inspecting secondary fields.
  • In Approach B (integer + scale): A payload of "quantity": 150 is indirected. The consumer must read quantity_unit.scale: 2 and divide by 10^scale. If an API client, analytics script, or LLM agent misses scale or defaults it to 0, the result is a 100x order error (150 lb instead of 1.50 lb).

B. The step: 0.25: Declarative UI vs. Runtime Rejection

Real-world merchants rarely sell measured goods in arbitrary power-of-ten increments (0.01). They sell according to discrete fractional rules (e.g., produce in 0.25 lb increments, fabric in 0.5 yd increments, or services in 0.25 hr / 15-minute blocks).

  • With Approach A (step: 0.25): The Platform UI receives an explicit declarative constraint. The cart can render +0.25 / -0.25 Stepper buttons (1.0, 1.25, 1.50) and instantly validate client-side before sending an API request.
  • With Approach B (scale: 2): The Platform only knows precision goes to 0.01. If a shopper types 1.37 lb, the Platform sends 137. Because power-of-ten scale cannot express quarter-pound rules, the Business is forced to reject the checkout with a runtime error ("Apples must be ordered in 0.25 lb increments"), degrading the shopper experience.

C. The Root Cause: Why fulfilled == total is Broken for Weighed Goods in BOTH Approaches

One main advantage justification for integer step counts (Approach B) is avoiding IEEE 754 binary floating-point accumulation bugs (0.1 + 0.2 === 0.30000000000000004) when deriving order completion:
$$\text{status} = \text{"fulfilled"} \iff \text{quantity.fulfilled} == \text{quantity.total}$$

However, in the physical world of grocery and catch-weight retail, the fulfilled weight almost NEVER equals the ordered weight.

  • A shopper orders 1.50 lb of apples (quantity.original = 1.5).
  • A picker weighs three apples at the store, and they come out to 1.48 lb (or 1.53 lb).
  • Because 1.48 != 1.50 (or in Approach B, 148 != 150), relying on mathematical equality (fulfilled == total) to determine line item completion is fundamentally broken for measured goods in BOTH Approach A and Approach B.

In real-world commerce, completion is not derived from equality; it is authoritatively declared by the Business. We should address the root cause by making two simple adjustments to our specification prose:

  1. Business-Authoritative Line Item Status:
    Update order_line_item.status prose so Platforms do not derive completion mathematically for measured goods:

    "Authoritative Line Item Status: order_line_item.status is authoritatively determined by the Business. For countable goods (each), a line item is typically "fulfilled" when quantity.fulfilled == quantity.total. For measured or weighed goods, the fulfilled quantity may legitimately differ from the ordered quantity (e.g., fulfilling 1.48 lb for a 1.50 lb order); the Business authoritatively sets status: "fulfilled" when the line item is satisfied according to its fulfillment rules."

  2. Canonical Quantity Equality (to prevent IEEE 754 float drift):
    Where quantity equality is evaluated, define a clean rounding invariant:

    "Quantity Equality and Status Derivation: When evaluating quantity equality or comparing floating-point quantity values, implementations MUST round values to 4 decimal places before comparison to prevent IEEE 754 binary floating-point representation errors. When an item declares sold_by.step, implementations MAY evaluate equality modulo that step: two quantities are equal if their absolute difference is less than half the declared step."

   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.
   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.
   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).
@igrigorik

Copy link
Copy Markdown
Contributor Author

Thanks @amithanda and @jingli, great flags and points. PTAL at the latest commits.

Declarative ordering granularity: landed as increment on the sale-basis descriptor — an integer count of scale-steps, so { "unit": "LBR", "scale": 2, "increment": 25 } reads "record to 0.01 lb, order in quarter-pound multiples." Platforms get steppers and client-side validation with exact integer math (no float multipleOf/modulo issues). It's deliberately advisory: increment bounds the ask, scale bounds the fact — exactly what catch-weight needs: ordering happens in 0.25 lb steps, and a 1.90 lb pick is still recordable.

Catch-weight: fully agree that fulfilled != ordered is normal, not exceptional. Order now shows the flow: the Business records the actual pick and reconciles with an adjustment that moves money together with quantity — after which fulfilled == total holds exactly. Completion stays business-authoritative through the records the Business writes, without decoupling status from quantity/money coherence.

First-call churn and error recovery: checkout now defines discovery explicitly. Platform learns the basis from the catalog, or omits the descriptor and reads the authoritative basis from the response echo — no error roundtrip; asserting a basis is opt-in verification of something previously discovered, not a forced guess. Mismatch handling also widened: the Business MAY convert an asserted basis to its authoritative basis as a visible line revision with a warning (one roundtrip for the cold-start case), or reject with a recoverable error.

Floats vs integers is still the spicy part...

There are two different problems hiding inside "quantity equality," and they need different tools:

  1. Representation error — float artifacts. Three picks accumulate to 1.4999999999999998 against a total of 1.5: logically equal, bitwise unequal.
  2. Commercial variance — physical reality. A 1.48 lb pick against a 1.50 lb order: genuinely different, by two real ounces of product.

Remedy 2 proposes one rule for each, but run the catch-weight example through them and they contradict each other. Rounding to 4 decimals only erases noise below 0.0001, so it correctly preserves the real difference: 1.48 != 1.50, and the line stays partial forever. Half-step tolerance goes the other way: |1.50 − 1.48| = 0.02 < 0.125, so the quantities are declared equal and the line completes — but because they're now "equal," nothing triggers a price reconciliation. The buyer pays for 1.50 lb and receives 1.48.

The integer wire dissolves problem 1 outright: step counts compare exactly in every language, so the spec carries no rounding or tolerance text anywhere. That leaves problem 2 to be handled as what it is — a commercial fact, not a numeric one: the Business records the actual pick and reconciles with an adjustment that moves money together with quantity, after which fulfilled == total holds exactly. It's the same reasoning that puts money in minor units, and the overview now states it directly... Under integers, no tolerance or epsilon text exists anywhere in the lifecycle; variance is a commercial fact settled through adjustments that keep money and quantity in sync.

Re, implementation burden

A fair concession first: unlike ISO 4217, scale is per-item data with no static table — which is precisely why the echo is a MUST: every authoritative line carries its own denominator, so interpretation never depends on an upfront catalog call. Beyond that, the cost is smaller than it reads:

  • Countable goods never see any of this. No descriptor, wire byte-identical to today.
  • Reading is not arithmetic. Shift the decimal point scale places, append display_text: 150 + { "scale": 2, "display_text": "kg" } → "1.50 kg" — the same code path for a Rec20 code and a unit you've never heard of.
  • You pay per policy used. increment defaults to 1; only businesses with coarser selling granularity declare it.

The examples now follow one grocery SKU end to end — catalog → pricing → increment snap → partial fulfillment with return → catch-weight reconciliation — plus a non-grocery conversion cameo.

@gsmith85 gsmith85 self-assigned this Jul 31, 2026
@gsmith85

gsmith85 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Edit (2026-08-03): Updated the proposal to communicate requirement for an arbitrary-precision decimal representation rather than an integer/scale pair. This provides direct human and AI agent readability ("1.50") while ensuring lossless parsing into BigDecimal / Decimal types across SDKs without binary floating-point representation errors.


Thanks @igrigorik, @jingyli, and @amithanda for the thoughtful discussion across PR #597 and PR #653.

Looking across both proposals, there appears to be a path to achieve the core requirements into a single, unified Measure datatype ([common/types/measure.json](file:///usr/local/google/home/smithgr/github/Universal-Commerce-Protocol/source/schemas/common/types/measure.json)). By encapsulating the measurement definition within Measure, we address the key design goals raised in both threads:

  1. Standardized Unit Vocabulary: Uses UN/CEFACT Rec 20 ID fragments (pound, kilogram, ounce, gram, fluid_ounce_(US)), facilitating cross-merchant price comparisons and agent reasoning directly. Following UCP's pseudo-enum pattern (as used in total.json and fulfillment_method.json), we enumerate well-known values inline in the schema prose while allowing businesses to use additional valid UNECE Rec 20 ID fragments.
  2. Lossless Decimal Precision Safety: Uses arbitrary-precision decimal strings (value: "1.50"), eliminating IEEE 754 binary floating-point representation errors during fulfillment accumulation, line-item reconciliation, and currency multiplication (Unit Price in cents × Quantity).
  3. Human & AI Agent Readability: Quantities like "1.50" are directly human-readable and generated naturally by LLMs without requiring prompt math or scaling conversions.
  4. Concise Countable Representation: Standard countable goods (the primary e-commerce case) remain simple, un-nested primitive integers ("quantity": 2) without requiring object wrappers.
  5. Backwards Compatibility: Existing integrations sending "quantity": 2 remain valid, avoiding breaking changes for current e-commerce workflows.

Proposed Measure Schema

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://ucp.dev/schemas/common/types/measure.json",
  "title": "Measure",
  "description": "Represents a count or quantitative measurement.",
  "oneOf": [
    { 
      "type": "integer", 
      "description": "Countable item quantity (semantically equivalent to an object with unit 'each').", 
      "minimum": 1 
    },
    {
      "type": "object",
      "required": ["value", "unit"],
      "additionalProperties": true,
      "properties": {
        "value": { 
          "type": "number", 
          "description": "Implementations MUST process value using arbitrary-precision decimal representations." 
        },
        "unit": { 
          "type": "string", 
          "description": "UNECE Rec 20 ID fragment. Well-known values: `one`, `each`, `pound`, `kilogram`, `ounce`, `gram`, `millilitre`, `fluid_ounce_(US)`. Businesses MAY use additional valid UNECE Rec 20 ID fragments." 
        },
        "increment": { 
          "type": "number", 
          "pattern": "^\\d+(\\.\\d+)?$", 
          "description": "Advisory ordering step multiple as a decimal string (e.g. \"0.25\").  Implementations MUST process value using arbitrary-precision decimal representations." 
        }
      }
    }
  ]
}

Payloads in Practice:

  • Countable goods: "quantity": 2
  • Weighed goods (1.50 lbs of apples):
    "quantity": {
      "value": 1.50,
      "unit": "pound"
    }
  • Weighed goods with ordering step (0.25 lb increments):
    "quantity": {
      "value": 1.50,
      "unit": "pound",
      "increment": 0.25
    }

This follows existing UCP schema conventions (such as fulfillment_destination.json) by encapsulating the polymorphic logic within the shared type, keeping line_item, expectation, fulfillment_event, and adjustment clean.

@amithanda

amithanda commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

On the "spicy part" (Floats vs. Integers) - I will propose to discuss a bit more on what to optimize for. I am still leaning more towards optimizing for simplicity and I think agent friendly WYSIWYG decimal number (1.5, step: 0.25) is a safer and more ecosystem-friendly contract than an indirected integer step count (150, scale: 2):

1. 4-Decimal Rounding ($10^{-4}$) NEVER Swallows Catch-Weight Variance

  • Proposing 4-decimal rounding ($10^{-4}$) is strictly an engineering guardrail for Problem 1: IEEE 754 binary floating-point noise (1.4999999999999998 == 1.5000).
  • Because 1.48 lb picked and 1.50 lb ordered differ by 0.02 ($2 \times 10^{-2}$), they are never equal under 4-decimal rounding (1.4800 != 1.5000). 4-decimal rounding never swallows commercial variance; catch-weight is a commercial event solved by a commercial record.

2. Catch-Weight: Commercial Remedy Works Identically in Floats

  • In the order example (docs/specification/order.md), the catch-weight line completed not because we used integers , but because the Business emitted an adjustment record that lowered quantity.total from 200 to 190 to match quantity.fulfilled (190 == 190).
  • When the Business lowers quantity.total from 2.00 to 1.90 in floats, 1.90 == 1.90 evaluates to true in floating-point math just as cleanly as 190 == 190 holds in integers. Both models rely on the exact same adjustment record to achieve fulfilled == total.

3. The "Scale Misread Hazard": Dynamic Denominators are 100x Worse than Float Noise

  • For currency (USD), scale is globally fixed and static by ISO 4217 (always 2).
  • In Approach B, quantity scale is dynamic per item and defaults to 0. If an API client, ERP script, or LLM shopping agent reads "quantity": 150 and misses scale: 2, it interprets 1.50 lb as 150 lb—a 100x order error. In floats, 1.5 is WYSIWYG everywhere.

4. Consider implications on SQL and AI Agents

  • SQL / BI Reporting: SELECT SUM(quantity) returns complete gibberish across mixed scales (150, 15, 1500) unless every SQL query is wrapped in dynamic quantity * POW(10, -scale) math. In floats, SUM(quantity) is natively correct (1.5 + 1.5 = 3.0 lb).
  • LLM / AI Agents: Autonomous shopping agents reason over WYSIWYG numbers (1.5) zero-shot. Forcing an LLM to inspect a secondary nested property (scale: 2) and divide before reasoning over cart quantities increases hallucination rates.

@amithanda

Copy link
Copy Markdown
Contributor

Thanks @gsmith85! Your first point—using UN/CEFACT Rec 20 ID fragments ("pound", "kilogram", "each", "millilitre") with inline well-known values in the schema prose—is great for readability for both human developers and LLM shopping agents, totally supportive of this.

Compared to opaque Rec 20 Common Codes ("LBR", "C62"), using readable ID fragments ("pound", "each") with an open tail prevents synonym drift (lb vs lbs vs pound) while remaining self-documenting.

Where I would caution against adopting a polymorphic oneOf [integer, object] for quantity is that **embedding { value, scale, unit, increment } inside every quantity field reverses one of the design point in the current PR and what we proposed on PR#597 **

Inheritance v/s Embedding Across the Order Lifecycle

In PR #597, placing the unit inside quantity created severe arithmetic coupling across the order lifecycle. That is why @igrigorik moved the descriptor to be declared once on item / variant in PR #653, so that all dependent records (order_line_item.quantity.{original,total,fulfilled}, fulfillment_event, adjustment) carry clean scalar numbers and inherit the line item's unit.
If we wrap quantity in a polymorphic Measure object across the lifecycle:

  1. Re-introduces Mismatch Hazards: Two records on the same line item could arrive with different scales ({"value": 15, "scale": 1} vs {"value": 150, "scale": 2}) or different units ("pound" vs "ounce"), breaking simple numeric comparisons (fulfilled == total).
  2. Payload Bloat: Every micro-fulfillment event and line item adjustment must repeat { "value": 150, "scale": 2, "unit": "pound", "increment": 25 } instead of sending a clean scalar.
  3. Fails on Variable-Weight Countables (@alex-jansen’s use case): A single polymorphic Measure object cannot express ordering 3 Honeycrisp apples (count: 3) priced at $1.99/lb with a 0.4 lb nominal size without conflating apple count with apple weight.

Also, see my comment (float v/s integer above) on leaning towards a more simplified representation which is easier for agents to read and understand and reduces chances of errors.

@igrigorik

Copy link
Copy Markdown
Contributor Author

@amithanda see: https://gist.github.com/igrigorik/0101b779b88635bf93cfea4eec6a9768

Let's step back, the meta conversation here is around precedence of applied principles for UCP / protocol design.

I fully agree that a decimal is easier to inspect and grok visually, but to me provably-correct-by-construction property takes strict precedence in API/protocol design. To elaborate, fully specified, both proposals compute integer step counts: "round to 4 decimals" is integer steps with scale frozen at 4, carried in a noisy container. So the real choice isn't floats vs. integers. It's:

  1. Where the invariant lives
    • type: integer: machine-checked at the protocol boundary
    • "Round before comparing" is not checkable by any validator
  2. How it fails
    • Misread scale: loud, deterministic, your first test, your bug
    • Float drift: quiet, value-dependent (order A closes, order B wedges), and lands on your counterparty
  3. What it costs
    • Integers: one decimal shift + display_text, paid only on measured goods
    • Decimals: four eternal MUSTs for precision ceiling, rounding mode, round-before-compare, canonical emission, which must be paid by every implementation, forever, and remain unverifiable
  4. What it assumes
    • With round to 4, we're introducing arbitrary precision cutoff where none is necessary
    • scale declares exactly the granularity each item actually has, chosen by the party that knows the item

At a meta level: correctness belongs in the type system and an error class that does not exist beats one that is not supposed to happen. Quantities multiply money, and thus I'm leaning on strict type system rules.

@igrigorik

Copy link
Copy Markdown
Contributor Author

@gsmith85 @amithanda three additional threads here...

Embedding vs declaring once

+1 to everything @amithanda said on inheritance vs embedding — that coupling hazard is exactly why #597 was revised into the declare-once shape.

One supporting point: embedding the unit in quantities can't remove the item-level declaration, because the price needs a unit before any quantity exists. A catalog variant says price: 1299 — per what? Also at catalog time there is no quantity object to carry that unit. So the variant declares it either way, and declare-once means the same descriptor denominates the price and every quantity. Worth noting too that the conciseness and backward-compatibility goals already hold today: an absent descriptor encodes each, so countable goods remain a bare "quantity": 2.

Unit vocabulary: codes vs readable names

On Rec 20 ID fragments ("pound", "fluid_ounce_(US)"): the readability goal is right, but the current design already delivers it. Every descriptor pairs the code with a required, Business-provided, localizable display_text ({ "unit": "LBR", "display_text": "lb" }). The code never travels without its label — the Business is the authority and either sources the label from Rec 20 or supplies its own. For an agent, the working job is trivially quantity + display_text; it never needs to interpret the code, while the code remains available for exact-match identity and verification whenever it wants it. So the open question is only which string is the comparison key, and there I'd push back:

  • The Common Code is the registry's stable identifier, with zero drift by construction. Readable names are where drift lives — lb/lbs/pound become plausible-looking identifiers instead of obviously-custom strings.
  • UN/CEFACT doesn't publish identifier-safe name strings; fluid_ounce_(US) implies a mangling convention and a derived list that UCP would author and maintain forever — a unit vocabulary through the back door.
  • Names-as-keys bake English into machine identity and (in the sketch) drop display_text, which is what guarantees localization and unknown-unit rendering today.
  • Precedent: schema.org uses exactly this split — unitCode (UN/CEFACT) + unitText (label).

Variable-weight countables / @alex-jansen's case

Three Honeycrisp apples at $1.99/lb, ~0.4 lb each: this needs two numbers (a count and a weight) and that's exactly why they occupy two slots in the current design. No single-slot quantity (polymorphic or otherwise) can carry both.

  • Count flows through quantity — the apples are sold by each, and the count is what the lifecycle tracks: quantity: 3, fulfill 3, return 1. Partial fulfillment of apples means whole apples.
  • Weight is the pricing story, which is unit_price's defined role: the variant quotes price: 80 (nominal: 0.4 lb × $2.00/lb) with unit_price carrying the per-pound rate and the nominal content weight (measure: 0.4 lb, reference: per 1 lb) — the shelf display reads "≈$0.80 each ($2.00/lb)".
  • Actual weight settles at fulfillment through the existing adjustment channel: picked weight comes to 1.14 lb instead of the nominal 1.20 lb, and a price_adjustment carries the totals delta (−12) with the count untouched. fulfilled == total holds on the count (3 == 3); money reconciles to reality.

Count in quantity, weight in unit_price, settlement in adjustments — all existing machinery, no schema change.

@igrigorik
igrigorik marked this pull request as ready for review August 3, 2026 18:18
@igrigorik
igrigorik requested a review from amithanda August 3, 2026 18:18
@igrigorik igrigorik added this to the Working Draft milestone Aug 3, 2026
@amithanda

amithanda commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@igrigorik , thank you for writing out the executable test cases in quantity-wire-footguns.sh—having concrete code to evaluate makes the trade-offs crystal clear.
I actually think that we should prioritize for simplicity of protocol design but even if I agree with the core tenet: a provably-correct-by-construction property takes strict precedence in API/protocol design. , when we test both models against distributed e-commerce architectures and grocery catalogs, the integer step-count model actually introduces an unverifiable error class that the other design eliminates by construction.

1. Integer Steps Create an Unverifiable Distributed Error Class (scale Drift)

In quantity-wire-footguns.sh, "quantity": 30 works because scale = 2 is hardcoded in the test script. In a multi-service architecture (Cart -> Checkout -> Order Service -> WMS -> Webhook), scale lives on a separate entity (item.quantity_unit.scale).
If a downstream warehouse webhook emits "fulfilled": 3 (operating in tenths of a pound), or if a catalog update shifts scale from 2 to 3, the type: integer schema validator cannot detect the corruption. Comparing 30 == 3 wedges the order forever, and 300 * price overcharges 10x.
Our decimal model is correct by construction: "quantity": 1.5 is self-describing and scale-invariant. Whether an upstream service emits 1.5 or 1.50, JSON 1.5 == 1.50 is mathematically identical across every parser on earth without relying on external scale synchronization.

2. Single-Descriptor quantity_unit Breaks on Variable-Weight Countables

How does a single quantity_unit model a Honeycrisp Apple (ordered by count "each", priced by weight "$1.99 / lb")?

  • If quantity_unit: LBR (scale 2), ordering 3 apples transmits "quantity": 120 (1.20 lb). When weighed at 1.28 lb, fulfilled (128) != ordered (120)—your status() script returns "partial" (BUG!).
  • If quantity_unit: C62 (each), quantity: 3 works, but you destroy the legal price per pound ($1.99/lb), violating retail grocery regulations.
    Our orthogonal descriptors (sold_by + priced_by) solve this cleanly: order by count (3), price by weight ($1.99/lb).

3. Your Script Highlights a Pre-Existing Bug in main That Our PR Fixes

Your script proves that defining completion as if (fulfilled == total) is broken. But that is a pre-existing flaw in main's current Status Derivation formula, not a flaw in decimals. Even in main today, naive equality fails on Catch-Weight goods (1.90 lb != 2.00 lb).
Our PR explicitly updates order.md to replace naive fulfilled == total derivation with Business-Authoritative Line Item Status. By making the Business server authoritative for status (where databases use exact NUMERIC types and know when a catch-weight pick is complete), we eliminate client-side float rounding and catch-weight bugs at the same time.

@igrigorik

Copy link
Copy Markdown
Contributor Author

@amithanda ty, we covered some of this on the TC call, but for posterity & broader audience...

Order records are self-contained

The sale basis is echoed on every cart, checkout, and order line response (normative MUST) — each line carries its own (unit, scale), frozen at transaction time. A catalog update shifting scale from 2 to 3 does not reinterpret in-flight orders, because there is no "external scale synchronization" to break: the scale isn't external. It travels with every quantity that crosses the protocol boundary.

Same plumbing breaks the decimal model identically, on the unit axis. A warehouse webhook emitting a bare 3 "in tenths of a pound" is exactly as corrupt as one emitting a bare 1.5 in kilograms against an order denominated in pounds. 1.5 self-describes the scale dimension only; the unit must travel with the number under both designs — and once the descriptor travels, scale rides along at zero marginal cost. "Self-describing" covers half the denominator.

Machine check exists, it's the assertion mechanism

Any party can assert the (unit, effective scale) it believes it is operating in; the Business MUST compare machine identities and resolve a mismatch visibly (convert or reject, recoverable). That is the stale-belief detector for the multi-service story, normative in this PR and checkable at the protocol boundary.

On "pre-existing bug in main"

main's quantities count whole items — catch-weight isn't representable there, so nothing pre-existing breaks. And decoupling status from the counts recreates the problem discussed upthread: buyer charged for 150 steps, receives 148, status declared fulfilled — nothing ever triggers reconciliation. The adjustment path (example in order.md) keeps status, counts, and money coherent, and the Business remains fully authoritative — through the records it writes, not a flag that overrides them.


Now back to the fun and 🌶️🌶️🌶️ part of the conversation...

Exact decimal is integer + scale, by definition

"Databases use exact NUMERIC types" — agreed, and look at what those are: NUMERIC, BigDecimal, Decimal are all an integer coefficient plus a declared scale (BigDecimal is literally documented as unscaledValue × 10^-scale). The exactness comes from never letting the value exist as a binary float.

The root cause of our debate is: JSON has no decimal type (one number type, double semantics in practice — and JavaScript has no native decimal either (TC39's decimal proposal remains Stage 1 after 12+ years of discussion). So an exact decimal can only be encoded: as a string ("1.50" is exact, but every consumer now needs a decimal library, and UCP would diverge from itself: money already travels as precision-specced integers), or as (this proposal) JSON integers (value + declared scale). Integer + scale isn't an alternative to Decimal; it is Decimal, serialized into JSON's only exact numeric type. Minor units are the same move — an amount is a Decimal whose scale lives in the currency table.

Quantities multiply money — the contracts can't be decoupled

The question is whether quantities can use a different contract than money, and they can't, because quantity is a factor in money arithmetic: price × quantity prices the line, price × quantity-adjustment prices an adjustment. Multiply an exact integer by a double and the product is a double — exactness dies at the multiply, before any rounding, no matter how pure the minor units were.

We're not the first to bump heads with this problem, two examples worth studying...

Dinero.js v1 shipped exactly the decoupled design: integer minor-unit money, float multipliers (price.multiply(0.055)). IEEE 754 errors in money results followed, and v2 removed floats from the API entirely: "operations like multiply, allocate, and convert no longer accept floating-point numbers... to prevent IEEE 754 rounding errors." Multiplying money by 3.2 is now spelled:

multiply(price, { amount: 32, scale: 1 });   // integer multiply, scales add, one explicit rounding

An integer quantity with a declared scale. They ran the decoupled experiment and reverted it. Separately, TC39's Stage 2 Amount proposal is an immutable value + precision + unit triple — new Amount(value, { unit, fractionDigits, roundingMode }) — motivated, in their words, by "it is rare to have a number by itself… from the number of apples in a bowl to the amount of Euros in your bank account," because a number separated from its unit and precision "causes real-world bugs." Same contract: number, unit, and precision travel together, and rounding is explicit.

UCP's proposed descriptor is the wire serialization of where the JS platform itself is heading.

Industry precedent & examples

Square declares a per-item CatalogMeasurementUnit.precision (integer, 0–5) in the catalog — "if left unset, the item will be sold in whole quantities" — and re-carries it on every order line via OrderQuantityUnit. That is effectively our PR structure — per-item scale, declared once, traveling with each line — in production.

Google's standard money types are scaled integers. google.type.Money: units (int64) + nanos (int32); Google Ads Money is amount_micros (int64, "one million is equivalent to one unit"). All the same reasons and motivation as ISO 4217 exponents that we adopted in UCP.

@amithanda

Copy link
Copy Markdown
Contributor

Thanks @igrigorik for sharing additional perspective on the design which provides strong justification against using decimal numer.

I want to step back a bit and narrow the open design issues into two axes, because I have been mixing them and that has probably made the thread harder than it needs to be.

  • Axis 1, carrier format. How a measured amount is written on the wire.
  • Axis 2, propagation. When ordering and pricing are measured differently, does the basis that determines the charge survive from catalog into a verifiable order.

They are independent. Axis 2 holds whichever carrier we pick.


Axis 1: carrier format

What I am actually arguing for

A quantity should mean what it says without a reader combining it with a field declared elsewhere. Integer steps plus scale is a coherent way to get exactness and I am not claiming it is wrong. I am claiming it is a pattern I could not find in a comparable system, and inventing one deserves a higher bar than adopting one. So I would like to pressure test three options rather than two:

  • A. decimal number, 1.5
  • B. integer steps plus scale, 150 with scale: 2 (this PR)
  • C. decimal string plus declared precision, "1.50" - proposed by @gsmith85

You highlighted some great challenges with decimal which I acknowledge. I had not seriously considered C before this, and I think it deserves a fair discussion as well before we decide to rule it out.

Why the currency analogy may not carry

The case for B rests on "quantities get money's representation, exactly as an amount relates to its currency." Highlighting some properties that I think are different and thus merits a discussion on different modeling approach.

Property that makes minor units work Does it hold for scale?
The domain is naturally discrete: a cent is legal tender Mass, length, area and volume are continuous
Precision is a property of the currency Precision is a property of the measurement

Discreteness. $1.005 is not a settleable amount, so an exponent of 2 discards nothing: minor units are lossless because the domain is already quantised. 1.005 lb is a real weight. scale is a quantisation choice imposed on a continuous domain, which means it makes a truth claim about the item that a currency exponent never makes. This is the part of the money analogy I think does not transfer, independent of encoding.

Whose property precision is. A store scale, a butcher's scale and a DC scale have different precisions, so the same SKU weighed in two places produces two precisions. scale is attached to the item, so a Business recording 0.01 lb in store and 0.001 lb at the DC cannot say so for one SKU. FHIR puts precision in the literal ("0.010 is regarded as different to 0.01"); Square declares it as a ceiling, "the maximum number of positions allowed after the decimal", without rescaling the value. Both keep "what this measurement is" and "what the Business can record" as two facts. scale fuses them into one number.

That fusion has an operational edge. quantity_unit says Business-authored records are "bounded only by scale", so every contributing system rounds into the line's declared scale, and sum-of-rounded is not rounded-sum:

three picks:  0.334 + 0.333 + 0.333 = 1.000 lb   exact
line declared at scale 2, each event rounds:
              0.33  + 0.33  + 0.33  = 0.99 lb
ordered = 100 steps, fulfilled = 99 steps  ->  fulfilled != total

A pick that was exactly right leaves the line at partial and needs a -1 step adjustment to close. The counter is "declare scale: 3", and it works, so I am not claiming this breaks. The cost is that the catalog-time declaration now has to anticipate the finest precision any fulfilment system will ever produce, a store scale, a DC scale, a third-party carrier, and covering the worst case inflates every integer on every line of that item. Under C the declared precision bounds what is asked for without bounding what is recordable. It is the same distinction Square draws with "maximum positions allowed".

How the ecosystem handles it

System Carrier Unit identity Closest to
Square Orders quantity as a decimal string, max 12 chars, with quantity_unit.precision 0 to 5 typed measurement_unit + custom C
Instacart unit token on the product (each, lb, bunch, head) readable tokens A/C
Shopify cart quantity is Int; Float only on the display comparator closed readable enum no fractional cart quantity
Google Merchant number plus unit, max 2 decimal places readable tokens incl. sqft, sqm, ct A
schema.org value as Number unitCode (Rec20) + unitText A
EN 16931 / Peppol / UBL BT-129 as xs:decimal, arbitrary precision BT-130, Rec20 with Rec21 A, exact type
HL7 FHIR value as decimal, precision in the literal UCUM code + display unit A/C
Stripe line quantity is integer; sub-unit precision lives on the price as unit_amount_decimal, a decimal string at up to 12 dp n/a, no physical units C for the precision carrier
UCP #653 integer steps + scale Rec20 + display_text B

The naive reading of this table is wrong, to be fair: schema.org and Google Merchant are description and feed layers that never accumulate a quantity or derive a state; UBL is XML with an arbitrary-precision decimal type; FHIR's decimal is explicitly not an IEEE float; Shopify's Float is on a comparator and its cart quantity is an integer. This table does not say "use JSON floats."

Three things I think it does say:

  1. Every system writes the measured amount as the amount, using an exact numeric type. None writes a scaled integer for a physical quantity.
  2. Where the transport lacks an exact numeric type, they reach for a string, not for scaling. JSON has no exact numeric type. The one JSON transaction API on the list, Square, writes quantity as a decimal string.
  3. On vocabulary, this PR is aligned with the strongest precedents. schema.org's unitCode/unitText and EN 16931's BT-130 are Rec20 codes with a separate display label, which is exactly unit plus display_text. I would keep that unchanged.

Pressure testing option C

{ "item": { "id": "var_bananas", "price": 79,
            "sold_by": { "unit": "LBR", "display_text": "lb", "precision": 2 } },
  "quantity": "1.50" }

Where it is strong:

  • Exact in every language. JSON hands you a string and you choose the decimal type, so there is no IEEE 754 exposure at all and no rounding prose anywhere.
  • WYSIWYG. "1.50" cannot be misread by a factor of ten, and there is no derivation step for an agent to skip on the write path.
  • It carries its own precision in the literal, the way FHIR decimals do, so a 0.01 lb store pick and a 0.001 lb DC pick are both representable for one SKU.
  • A declared precision stays useful and stops being redundant: the literal says what this measurement is, precision says what the Business can record. Those are the two facts scale currently fuses.
  • Shipped precedent for exactly this use case.

Where it is weak:

  • JSON Schema loses native numeric validation. minimum: 1 on line_item.quantity becomes a pattern or prose.
  • String equality stops matching numeric equality once trailing zeros are significant, so comparison needs a stated rule.
  • Codegen produces string-typed fields, which is worse SDK ergonomics than an integer.
  • UCP has no existing string-number precedent; amount is an integer.

Axis 2: propagation

Quotable is solved. Verifiable is the open question.

The PR body already has the right escape hatch for count-ordered, weight-priced goods:

"Other characteristics of a sale unit may affect the Business-quoted price without changing this denominator."

A whole salmon at $12.99/lb, nominally 3.2 lb: sold by each, price: 4157, quantity stays 1, fulfilled == total holds trivially, a 3.41 lb pick becomes a money-only adjustment, quantity.total never moves. That works, and it is cleaner than the weight-ordered banana case in the PR's own examples. So the question is not whether this category can be quoted.

The question is narrower: once the price is set by a measurement, does that measurement, and the rate applied to it, survive into the order, where a buyer, an auditor, or a payment processor would need to check it. I will call that propagation, to separate it from expressiveness.

The receipt test

Take your own 8/3 prescription for this exact category, since it is the clearest statement of the design and I would rather test it than a case I invented:

Count flows through quantity [...] Weight is the pricing story, which is unit_price's defined role: the variant quotes price: 80 (nominal: 0.4 lb × $2.00/lb) with unit_price carrying the per-pound rate and the nominal content weight [...] picked weight comes to 1.14 lb instead of the nominal 1.20 lb, and a price_adjustment carries the totals delta (−12) with the count untouched.

Run it through: 3 apples ordered, 3 x 80 = 240 signed at checkout. Picked weight comes in under nominal, 1.14 x 200 = 228, and a -12 adjustment reconciles it. Here is the settled order, in full:

{ "line_items": [ { "id": "li_apples", "item": { "id": "var_apple", "price": 80 },
                    "quantity": { "original": 3, "total": 3, "fulfilled": 3 },
                    "totals": [ { "type": "total", "amount": 240 } ], "status": "fulfilled" } ],
  "adjustments": [ { "id": "adj_1", "type": "price_adjustment", "status": "completed",
                     "line_items": [ { "id": "li_apples", "quantity": 0 } ],
                     "totals": [ { "type": "total", "amount": -12 } ] } ] }

The test: hand this to someone checking their receipt and ask them to verify the $12 credit. They cannot. price: 80 is per-apple. quantity: 3 is a count. The adjustment says $12 moved and, at best, why in free text. Nothing here is $2.00, nothing here is 1.14 lb, and there is no field where either could go, because item carries only id, title, price, quantity_unit, image_url. The rate and the measurement both existed, on variant.unit_price, at catalog time, one hop away.

Why this is structural, not a gap in one example

quantity_unit and unit_price take different paths through the pipeline, and the difference is visible in the schemas independent of any example:

Stage Ordering basis (quantity_unit) Pricing basis (unit_price)
Catalog, variant present present
Cart, line_item.item present, inherited absent
Checkout, line_item.item present, inherited absent
Order, order_line_item.item present, inherited absent
Adjustment implied via line_item_id absent, description only

quantity_unit is declared once and echoed on every downstream record as a normative MUST, which is exactly the design unit_price needs and does not have. It is defined as a display comparator the Platform "MUST NOT recompute", and it stops at the catalog. For any item where ordering and pricing share one basis, that is fine, unit_price really is decoration. For the apples case it is the only place the transactional rate exists, and it does not travel.

Not a grocery-only shape

The PR's own catalog prose contains a second instance of the same fork:

"a 50 m cable spool sold by each can omit quantity_unit while carrying a unit_price per metre"

Schema-identical to the apples case. For a spool the per-metre figure is a shelf label and unit_price staying display-only is correct. For catch-weight protein, flooring priced per m² sold in boxes, or lumber priced per board-foot sold by the piece, the identical shape carries the number the charge is actually computed from. Nothing in the schema marks the difference, so a reader cannot tell by inspection which case they are looking at.

This also matches how the rest of the ecosystem treats it: schema.org gives the pricing basis its own field with its own unit (referenceQuantity), EN 16931 does the same (BT-149/150), and Stripe expresses "priced per N units" as transform_quantity on the price rather than folding it into the quantity. In each of those, the pricing basis is first-class and travels with the transaction. Here it is catalog-only.

I am not proposing where the fix goes. The question I would like on the table is narrower: when a Business-quoted price is set by a measurement, should that measurement travel into the order the same way the sale basis already does?

@gsmith85

gsmith85 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

I primarily focused on the 🌶️🌶️🌶️ part, but here are some observations and conclusions heading into our discussion coming out of my deep dive prep:

Observations

1. ISO 4217 & Fractional Minor Units

Regarding the argument about losing exactness at the point of multiplication: rounding is mathematically inevitable regardless of integer purity if we're ISO 4217. ISO 4217 fixes the precision of the currency side of the operation, so you can end up with accumulated loss from minor unit truncation. For a continuously weighted good sold at $1.50 / kg, a purchase of 1.33 kg yields $1.995, which must ultimately be rounded. If the implementor isn't careful, these errors can grow in aggregate.

Relatedly, relying universally on ISO 4217 integer amount fields presents a limitation: it cannot model sub-cent / fractional minor units. It struggles to cleanly accommodate several commerce categories, including:

  • Fuel & fuel surcharges ($3.45⁹/gal)
  • Bulk hardware & construction supplies (e.g., screws sold by weight/thousand)
  • Metered energy & utility tariffs (kWh)
  • SaaS & cloud API usage ($0.0015 / token)

Stripe, who uses ISO 4217 predominantly, does use decimal representations for currency in their APIs as well to accommodate these scenarios.

3. Industry Precedents around Precision: Square & Stripe

Square: Uses an explicit precision parameter (which parallels scale in intent), but pairs it with a string quantity:

    {
      "quantity": "0.50",
      "quantity_unit": { "precision": 2 }
    }

Square’s approach maintains direct API readability while declaring validation bounds.

Stripe: Uses ISO 4217 integer cents primarily for settlement and transfers across banking systems (merchants deal in fractional amounts; banks settle in whole minor units). For rates and quantities, Stripe uses decimal strings bound to 12 decimal places across their specification (e.g., unit_amount_decimal, quantity_decimal).

4. On numeric types

Stripe in particular, but others as well, use a combination of integer and string types to model integers and decimals respectively. To the extent floats are used they are in contexts where the math is less sensitive (e.g. discovery and display contexts) or the risk of rounding error is bounded.

High Level Conclusions

  1. JSON Schema number (float) is insufficient: We are building a financial and commerce execution protocol; we must maintain exact precision and eliminate floating-point drift. So we should put this aside for the purpose of debate.
  2. I did not encounter an example of a numerical pattern matching what's currently proposed in this PR and for that reason I would prefer anchoring on established String patterns that I additionally prefer for their ergonomics.
  3. Even if we continue using amount as an integer, I think it's likely we end up with some diversity in numerical representation as different situations will require different handling.
  4. Human readability is a developer affordance: Decimal strings ({"quantity": "3.5"}) are easier for developers to read than scaled integers ({"quantity": 35, "scale": 1}). Across documentation, code samples, API logs, and real-time debugging, "3.5" is instantly self-describing. It eliminates the need for mental exponent math to propagate through implementations.
  5. SDK code generation is manageable: While string representations require language-specific decimal handling (e.g., BigDecimal), we can adopt Stripe’s convention of using a standard _decimal postfix. This gives SDK generators a deterministic hook to map string fields to native BigDecimal types automatically, while providing non-SDK implementers a clear hint to use an arbitrary-precision decimal library.

Proposed UCP Numeric Selection Philosophy

We should codify our numeric formatting preferences in the core UCP documentation for posterity, a draft take would be something like:

• Integers (64-bit): Used for financial settlement (amount in ISO 4217 minor units) and discrete, indivisible whole counts.
• Strings (_decimal): Arbitrary-precision decimal strings (capped at 12 decimal places). Used for continuous/weighted quantities ("1.45") and sub-cent unit prices/rates ("0.0015").
• Numbers (64-bit Floats): Reserved exclusively for non-compounding multipliers, percentages, and metadata where downstream rounding at invoice closing handles float noise (e.g., tax_rate: 8.875, exchange_rate: 1.08425, geographic-coordinates, fraud scores).

Closing Thoughts

A one-size-fits-all approach (i.e., using only integers) will struggle to endure the full diversity of commerce scenarios UCP will encounter. Codifying a clear, multi-tier numeric philosophy gives us an opportunity to resolve this debate broadly and permanently for the project.

   When an item is priced by a measurement different from its sale basis
   (apples at $2.00/lb, sold per each), the rate that determines the
   charge previously stopped at the catalog, leaving settled orders
   unverifiable from their own records.

   The fix is the sale-basis symmetry rule, applied a second time:
   quantity_unit MUST travel on line responses when the sale basis is not
   `each`; unit_price MUST travel on every cart, checkout, and order line
   whose pricing basis differs from its sale basis. Presence on the line
   is the marker: a line-level unit_price carries the transactional rate,
   a catalog-only one is a display comparator. unit_price is extracted to
   a shared type (shopping/types/unit_price.json) mounted on variant and
   item (response-side).

   Adjustments gain an optional settled measure (the shared measure
   type): the Business-recorded measurement a price settlement
   reconciles. Its unit identity MUST match the line's pricing basis, and
   quantity: 0 is the pure-price-settlement form, leaving the count
   lifecycle untouched. A settled measure is Business-recorded fact and
   lives in core; Buyer-configured measurements that define item identity
   remain a separate negotiated extension on the same primitive.

   order.md adds the count-sold, measure-priced example: 3 apples at
   $0.80 each (nominal 0.40 lb x $2.00/lb), picked weight 1.14 lb.
   Nominal 3 x 40 = 120 steps, settled 114, delta 6 steps x 200 x 10^-2 =
   12 - the -12 adjustment verifies from the order alone, in integer
   arithmetic.
@igrigorik

Copy link
Copy Markdown
Contributor Author

@amithanda good catch on the receipt test -- agreed, and addressed via 449c189. The satisfying part is that no new machinery was needed, the fix is the existing symmetry rule applied a second time:

  • quantity_unit MUST travel on line responses when the sale basis != each
  • unit_price MUST travel on line responses when the pricing basis != the sale basis

Please double-check the logic.

@igrigorik

Copy link
Copy Markdown
Contributor Author

For the representation, I'll attempt to recap where we're at...

I believe we converged and agree that bare JSON numbers are out. Both remaining options encode the same canonical pair (integer coefficient + declared scale + unit) and that pair is the well-trodden design everywhere exactness exists: BigDecimal, SQL NUMERIC, Dinero v2, minor units, TC39's (wip) Decimal, etc. So we're not inventing a numeric pattern, we've successfully re-derived an established and widely used one.

The debate is how it's applied to UCP:

  • does the coefficient travel as a JSON integer or a decimal string
  • do quantities share money's regime or get their own

My strict priority stack for evaluating the options is: correctness > cost of implementation > presentation.

The protocol is judged by its laziest consumer, not by its most diligent. The job of protocol designer is to eliminate and prevent footguns where possible; to define a protocol that yields right outcomes by construction where possible, not by normative prose and appeal to diligence. Conversely, this means preferring strict and early validation and loud and obvious errors. For a commerce protocol, money and inputs that interact with it are load-bearing, and thus my strong belief in above priority stack for this discussion.

For me the int path wins because...

A) Exactness is unconditional vs opt-in. An integer is exact in every JSON parser ever shipped — there is no lazy path to corruption. A string is exact until the first Number("1.50") at which all guarantees are out the door; and we all know this is and will be the most natural path.

B) Validation can be enforced at the boundary, consumer failure is loud. type: integer / minimum / multipleOf validate at the boundary — by emitter and receiver alike; failure to account for precision yields loud and obvious display error -- "you're going to charge me how much? you're going to order how many lbs of potatoes?"

C) It's self-consistent and coherent contract across the protocol. We already mandated minor units for prices, enforcing same contract for quantity does not introduce net new cognitive load. Further you're operating in same regime and have guaranteed consistency; if we mix strings and minor units then we're asking you to double the work (do minor units here, but make sure to do proper decimal parsing over there), and forfeit properties outlined above.

To be clear, yes the explict tradeoff is that presentation on the wire comes last in this priority stack, but that -- to me -- is a worthwhile and obvious trade, because correctness and self-consistency are more important, and our tooling and AI agent friends can easily be taught to do trivial decimal-point shift operations when operating on these primitives -- and one consistent contract is strictly better than mixed regime.

   Close the two numeric follow-ups from TC discussion of the integer
   wire: prescribe integer range (Greg's 64-bit flag) and add ingestion
   guidance for data leaving the protocol boundary (Amit's request).

   Every integer-valued field is now capped at +/-(2^53 - 1), the range
   within which every JSON implementation agrees exactly on integer
   values (RFC 8259, Section 6) and within which JCS canonicalization -
   required for AP2 mandate signing - is defined. The bound is enforced
   by schema, not prose: minimum/maximum added to amount, signed_amount,
   measure.value, and every quantity field, so an out-of-range value is
   schema-invalid and rejected by existing validation. Verified
   empirically: parsers agree at 2^53 - 1 and diverge one integer past it
   (a big-int parser yields ...993 where a double-based parser yields
   ...992 and re-serializes the corruption).

   The same cap derives a maximum for scale: at scale 16, one whole unit
   (10^16 steps) exceeds the emittable range, so scale gains maximum: 15
   - a bound derived from the range rule rather than chosen, superseding
   the earlier no-arbitrary-cap stance and dominating shipped precedent
   (Square 5, micros 6, nanos 9, Stripe 12).

   Behavioral contract kept to the two things schemas cannot check:
   arithmetic MUST be exact (in-range products such as amount x quantity
   can exceed 64 bits, so use wider integers or overflow checks), and an
   implementation that cannot produce an exact, in-range result MUST
   surface an error rather than emit, display, or act on an approximate
   or wrapped value. Within the wire range, IEEE 754 binary64 - a
   JavaScript Number from JSON.parse - holds every integer exactly; the
   hazard is arithmetic, not representation.

   Ingestion guidance: convert once at the boundary into an exact decimal
   type (NUMERIC, BigDecimal, Decimal) or carry the (value, scale) pair;
   no scale application or value-bearing arithmetic in binary floating
   point.
@igrigorik

Copy link
Copy Markdown
Contributor Author

@amithanda @gsmith85 updated to capture outstanding feedback from our review. PTAL, hopefully last and final pass!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants