feat!: define sale-basis steps for quantities - #653
Conversation
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.
|
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 1. What I think is aligned with the #597 design option:
2. The One Key Difference: Approach A vs. Approach BWhere this PR diverges from that proposal is in how numeric quantities and step granularity are represented on the wire:
3. I am leaning towards Approach A (
|
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).
|
Thanks @amithanda and @jingli, great flags and points. PTAL at the latest commits. Declarative ordering granularity: landed as 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 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:
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 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 Re, implementation burdenA fair concession first: unlike ISO 4217,
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. |
|
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
Proposed
|
|
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 1. 4-Decimal Rounding (
|
|
Thanks @gsmith85! Your first point—using UN/CEFACT Rec 20 ID fragments ( Compared to opaque Rec 20 Common Codes ( Where I would caution against adopting a polymorphic Inheritance v/s Embedding Across the Order LifecycleIn PR #597, placing the unit inside
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. |
Context: #597. This PR drafts alternate solution shape...
UCP's integer
quantityhas 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 keepsquantityas an integer count of steps and addsquantity_unitto define their denomination and granularity. One step is10^-scaleofunit. The (unit, effectivescale) pair is the unit descriptor's machine identity; it neither identifies the purchasable item nor exhaustively describes one sale unit. Omittingquantity_unitfrom authoritative data preserves the existingeachbasis.This paves the way for...
1. Each or fixed package
quantitycounts 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_unitdenominatesquantity. 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: 150means 1.50 kg anditem.price: 1299is 1299 minor units per whole kilogram. The Business computes1299 × 150 × 10^-2 = 1948.5and rounds once to the authoritative line total of1949($19.49).The same steps flow through the order lifecycle:
{ "quantity": { "original": 150, "total": 150, "fulfilled": 50 }, "status": "partial" }Here
fulfilled: 50means 0.50 kg and an adjustment of-25means a 0.25 kg return—all in one inherited unit.Key design decisions
unit, effectivescale).scaledefaults to0; requireddisplay_textis presentation-only. This identity defines the denomination, not the complete purchasable item.C62representseachand cannot use a nonzero scale.display_text. Package codes are excluded because packages count aseach.unit, effectivescale). Businesses MUST echo non-eachdescriptors and surface mismatches; neither side silently converts quantities.price × quantity × 10^-scale, round once at the line, and return authoritative totals.Compatibility and migration
Backward compatible:
quantityremains an integer everywhere.quantity_unitis optional; omission retains the existingeachwire representation.Breaking:
This is a Core Protocol
feat!change because existingunit_priceproducers must migratemeasureandreferencefrom decimal/free-form measurements to positive integer values plus the shared descriptor (unit,display_text, optionalscale). 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