Skip to content

feat!: Support fractional quantities and unit measurement in lower funnel capabilities - #597

Open
jingyli wants to merge 5 commits into
Universal-Commerce-Protocol:mainfrom
jingyli:feat-catalog
Open

feat!: Support fractional quantities and unit measurement in lower funnel capabilities#597
jingyli wants to merge 5 commits into
Universal-Commerce-Protocol:mainfrom
jingyli:feat-catalog

Conversation

@jingyli

@jingyli jingyli commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Description

Addressing a gap that would extend existing UCP shopping service schema/capabilities to support hyper-local goods (i.e. grocery) use cases:

  • Weighted and Measured Items: Right now, quantity representation is rigid and can only express countable goods.

Key Design Decisions

Unified anyOf Union for quantity

  • Problem: We need to support fractional weights (e.g., 1.5 lbs) while maintaining integer support for standard items.
  • Solution: Modify the quantity property to be an anyOf union of a standard integer and a $ref to a new shared common/types/measure.json object. An example of the modification in line_item.json:
    "quantity": {
      "anyOf": [
        {
          "type": "integer",
          "description": "Quantity of the item being purchased.",
          "minimum": 1
        },
        {
          "$ref": "../../common/types/measure.json",
          "properties": {
            "value": { "exclusiveMinimum": 0 }
          },
          "description": "The precise fractional quantity and unit of the item being purchased."
        }
      ]
    }

Category (Required)

Please select one or more categories that apply to this change.

  • Core Protocol: Changes to the base communication layer, global context, or breaking refactors. (Requires Technical Council approval)
  • Governance/Contributing: Updates to GOVERNANCE.md, CONTRIBUTING.md, or CODEOWNERS. (Requires Governance Council approval)
  • Capability: New schemas (Discovery, Cart, etc.) or extensions. (Requires Maintainer approval)
  • Documentation: Updates to README, or documentations regarding schema or capabilities. (Requires Maintainer approval)
  • Infrastructure: CI/CD, Linters, or build scripts. (Requires DevOps Maintainer approval)
  • Maintenance: Version bumps, lockfile updates, or minor bug fixes. (Requires DevOps Maintainer approval)
  • SDK: Language-specific SDK updates and releases. (Requires DevOps Maintainer approval)
  • Samples / Conformance: Maintaining samples and the conformance suite. (Requires Maintainer approval)
  • UCP Schema: Changes to the ucp-schema tool (resolver, linter, validator). (Requires Maintainer approval)
  • Community Health (.github): Updates to templates, workflows, or org-level configs. (Requires DevOps Maintainer approval)

Checklist

  • I have followed the Contributing Guide (including Conventional Commits title requirements and ! for breaking changes).
  • I have updated the documentation (if applicable).
  • My changes pass all local linting and formatting checks.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • (For Core/Capability) I have included/updated the relevant JSON schemas.
  • I have regenerated Python Pydantic models by running generate_models.sh under python_sdk.

Screenshots / Logs (if applicable)

@jingyli jingyli added this to the Working Draft milestone Jul 17, 2026
@amithanda amithanda added the TC review Ready for TC review label Jul 17, 2026
@jingyli
jingyli marked this pull request as ready for review July 17, 2026 18:10

@jamesandersen jamesandersen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @jingyli for introducing a formal measurement type to reuse across UCP. I left a couple comments inline for some things I've encountered working in this domain in the past.

"value": { "type": "number", "description": "Package quantity." },
"unit": { "type": "string", "description": "Unit of measurement." }
}
"$ref": "../../common/types/measure.json",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to make this an array of saleable units to support the grocery use case? e.g. with a wide variety of units a business may offer multiple units e.g. "bags" and "ounces" of black beans?

@jingyli jingyli Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good callout! Some thoughts:

  • I think the most common use case I can see is exactly like the one you shared above: a business offering to sell black beans in bags and ounces OR banana in lbs or each. However, my naive thinking is that in these cases, it is almost always the case that the unit_price associated with the measurement will be different. Right now in our Catalog capability modelling, each variant is associated with exactly 1 unit_price - https://github.com/Universal-Commerce-Protocol/ucp/blob/main/source/schemas/shopping/types/variant.json#L72. So if we do want to support multiple units, this would be a more fundamental change. An alternative here would be to have 2 variants of the same product if the unit measurement differs?
  • From a practical angle, I wonder if business really need to present all the possible units for the variant back to the platform vs. having control over what they want to disclose (i.e. a business may want to optimize based on contextual hints like if the platform represents a US user, then the preferred measurement returned will be in USCS).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For reference, the way we handle this within our business, is that you have 1 unit and unit price per sku (variant). This generally needs to be the case for a couple of reasons, but the two major ones are inventory tracking and analytics.

For our grocery customers, they do a lot of buy online pick up in store. If we are computing inventory on two different selling units for a single product, it becomes more complex and risk the change of over selling.

"items": {
"type": "string"
},
"description": "Variant tags for categorization and search."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the grocery use case in mind there are some key product/variant metadata that users need to be aware of during consideration because they have implications for payment and/or fulfillment e.g.

  • SNAP EBT eligibility - the item qualifies for use of an EBT payment method
  • Firearm Owners ID verification required, etc. - The ID must be shown to pickup items. (NOTE: while it seems strange to mention this in the context of grocery, many large grocery stores in the US at least have large sporting goods sections as well)

This may be belong in a separate PR but - in the interest of platforms / businesses mutually understanding some of these concepts I'm wondering if a schema of domain specific standard tags for these types of consequential concepts makes sense.

Minimally, if we intend for either tags or metadata to be use to carry these types of attributes would we want to tweak the descriptions?

@jingyli jingyli Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these are good prompts for us to continue this train of thought/use cases in a follow-up PR!

  • For SNAP EBT eligibility, I'm very curious how we want to model that. Because holistically I see this as a gap on FoP eligibility at the product-level (right now we allow the advertisement of available_instruments at the business/payment handler level, but nothing more granular in Catalog). A similar use case here would be whether a product can be purchased with giftcards or loyalty points.
  • Verification needed - aligned with tweaking the tag description at the minimum - will make the change (I also think these verification tags probably live more at the product level instead of at the variant level). We can follow-up on whether a more expressive construct will be needed separately.

"type": "object",
"additionalProperties": true,
"properties": {
"location": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we call this retail_location to match the fulfillment extension? (and avoid confusion with "locality")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to be a bit more forward looking on this field given #589 . The eventual thinking is that context will no longer be a shopping vertical specific construct so prefixing it with retail_ feels a little bit off to me.. The same field in my mind can be, for example, used to hint at a user's preferred restaurant under food ordering.

That being said, I'm also open to prefix it in this PR here and drop the prefix in #589.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha - yeah I was searching for some other options (e.g. venue, site, etc) that might be a little more distinct from "locality" but those also seem to have stronger coupling to certain verticals; finding something generalized does make sense. Thanks!

"quantity",
"totals"
],
"properties": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the grocery use case I think we'll need a line-item level note field. Some examples:
Simple Guidance

  • "Make sure the bananas are green"
  • "Find the ripest mango - I'll need it next week"

Substitution guidance that, during fulfillment, could be captured in the order adjustments:

  • "Substitute with non-organic if organic isn't available" ... or "Don't substitute"
  • "Substitute with the store brand if this brand isn't available"
  • "Get two dozen medium eggs if large aren't available"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noticed #607 that relates.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the callout! I think we also have #481 that also relates to this point here - somewhat (it can be an optional text input).

Fully aligned that having some optional free text field to indicate customer substitution preference is valuable, but I'd love to take the discussion to those PRs (and I do naively feel we will need only one of those mechanisms and probably not both..).

"type": "object",
"additionalProperties": true,
"required": ["value", "unit"],
"properties": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we put the step quantity in here as well? For example, selling in 0.01 increments of grams. When this is used by one of the catalog APIs, it is hint to the UI on what the purchasable quantity is. (i.e a drop down in some cases). It can default to 1 and it can also support client side validation.

Do we need to make units mandatory? Can it just be optional? If we make it optional, then instead of using "anyOf" when referencing this, can we just get rid of the integer version and keep this in a backwards compatible way?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we put the step quantity in here as well?

A good idea! But I think we need to give it a bit more thought on how this would fit in with respect to where measurement is being used in catalog today before introducing the field (adding it in a follow-up should be a non-breaking change).

This PR simply refactors out the construct from variant.unit_price.measure, based on where it is being used (as part of unit_price), I don't think it makes sense (at this stage) to introduce step_quantity.

Do we need to make units mandatory? Can it just be optional?

I think for any quantity measurement that goes beyond whole number counts, unit is required to remove any ambiguity on what the quantity is being measured against (and that's also why I slightly prefer the anyOf composition more since it cleanly differentiate between the whole number counts from fractional quantities). Note that even if we make unit optional and drop the integer version, this still won't be a backwards compatible change as we are changing the semantics/type of the original quantity field (and that would be considered a breaking change in UCP per https://ucp.dev/latest/specification/overview/#breaking-changes).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that making unit optional is the weaker path, and that dropping the integer branch is breaking regardless.

But I think there is a third option that keeps your disambiguation requirement while still collapsing the union: a single measure type where unit stays required, and countable goods carry a count unit rather than a separate integer branch. For example { "value": 2, "unit": "each" } for eggs and { "value": 1.5, "unit": "lb" } for weighed goods, instead of anyOf[integer, measure]. This is exactly how schema.org QuantitativeValue and GS1 model it, treating count as just another unit.

The trade this makes: every consumer reads one shape (value + unit) with no integer-or-object branching across the five schemas, and order_line_item no longer has to carry two parallel object shapes that cannot mix (integer total with a measured fulfilled). Since this PR is already feat!, the breaking cost is being spent either way.

The main downside is verbosity on the common countable case, but a readable count token like each (rather than a raw code) keeps { "value": 2, "unit": "each" } close to 2 in practice. Would it be worth weighing this unified shape against the anyOf, given it preserves the required-unit guarantee you want but leaves consumers with a single parsing path?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I was thinking about this a little differently. While going from an integer to a number might change a code generator or change an SDK, relaxing from an integer to a decimal is backwards compatible from a JSON standpoint.

Also, if we may unit mandatory, and set a default value of "each", wouldn't that also satisfy the backwards compatibility requirement? There appears to be precedence of default values within the schemas already (unless I am misinterpreting something).

Also, if we are not telling the platform what the limits are, how will it know that can't send quantities more granular then we support? i.e. if we are selling in int 0.01 increments, but the platform sends the business a 0.001 quantity then it will get rejected and end up with shopper facing error.

Happy to chat.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@amithanda The third option makes sense and fair callout on this PR already feat! so the breaking cost is already spent! The only worry I have with replacing the definition entirely is that this will introduce one additional layer of nestedness with the data structure for 90% of the eCommerce use cases we have (when unit = each). The existing anyOf would keep all current integrations compliant, and only disambiguate for the 10% scenario where a weighted/fractional quantity is needed. I personally feel like introducing another breaking change in the future to collapse the definition is "easier" than having to revert back this design when we realize it won't perform well with agent reasoning.

@sdedeo2025 Even using a default value on unit won't make this change backwards compatible because we are changing the semantics of the field - instead of quantity: 1, we will now have quantity: { "value": 1 }.

Also, if we are not telling the platform what the limits are, how will it know that can't send quantities more granular then we support? i.e. if we are selling in int 0.01 increments, but the platform sends the business a 0.001 quantity then it will get rejected and end up with shopper facing error.

While it's not an optimized experience, an available path here is for business to return a recoverable error outlining what is an acceptable quantity (maybe also the step_quantity as part of the description) so platform can reason and prompt/surface recovery paths to the shopper. This is somewhat similar to what we have today when platform requests for more quantities than what business has in stock.

@gsmith85 gsmith85 Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I lean toward @amithanda's point here that carrying duplicate anyOf[integer, Measure] blocks across 5 separate schemas creates unnecessary duplication and risks future pattern inconsistency.

Since UCP already uses polymorphic shared types where appropriate (such as fulfillment_destination.json encapsulating shipping vs. retail location), an alternative is encapsulating the union inside common/types/measure.json itself.

What this looks like in practice:

1. Centralized measure.json:

{
  "$id": "https://ucp.dev/schemas/common/types/measure.json",
  ...
  "oneOf": [
    { "type": "integer", "description": "Item quantity with an implied unit of 'each'." },
    {
      "type": "object",
      "required": ["value", "unit"],
      "additionalProperties": true,
      "properties": {
        "value": { "type": "number", "description": "The quantitative magnitude of the measurement." },
        "unit": { "type": "string", "description": "Unit of measurement (e.g. each, kilogram, ...)." }
      }
    }
  ]
}

2. Clean $ref across shopping schemas:
In line_item.json, expectation.json, fulfillment_event.json, adjustment.json:

"quantity": {  "$ref": "../../common/types/measure.json" }

3. Payloads:

  • Countable items: "quantity": 2 (un-nested, lightweight primitive)
  • Weighed items: "quantity": { "value": 1.5, "unit": "lb" }

This approach eliminates schema duplication* by using just $ref measure.json in place of the anyOf block and preserves simple primitives by still allowing compact representation of typical e-commerce purchases that are implicitly "each" (quantity: 2) .

If we prefer avoiding polymorphism, I would bias towards using a single Measure object with an optional default unit: "each" to limit verbosity.

@sdedeo2025

Copy link
Copy Markdown

Is it worth splitting out the fractional quantity work into its own PR? Why very common for grocery, I have seen it used in many other uses cases: 1/2 foot sections of length of fabric, parking spots in 1/4 hour increments, etc.

@jingyli jingyli self-assigned this Jul 23, 2026
@amithanda
amithanda self-requested a review July 24, 2026 01:07
@jingyli jingyli changed the title feat!: Support fractional quantities and location context for grocery shopping use cases feat!: Support fractional quantities and location context Jul 24, 2026
@jingyli

jingyli commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@sdedeo2025 Good prompt with the additional use cases! I realized that even in the PR description, I've alluded to grocery being only one of the use cases motivating this enhancement. Like you rightly called out, this should also support businesses who offer goods in other categories like fabric & crafts, flooring, home & garden, etc.

I've dropped the grocery shopping specific wording from the PR title so the scope remains a bit more generic.

"type": "number",
"description": "Precise fractional quantity."
},
"unit": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than a free-form unit, should we keep the JSON type as string but specify in the spec prose that it must carry a UN/CEFACT Rec 20 code, with an optional unit_text for display? For example { "value": 1.5, "unit": "LBR", "unit_text": "lb" } for weight, or { "value": 2, "unit": "C62" } for a countable each.

A few reasons this feels like the right fit:

  • Interop for free: Rec 20 is the same vocabulary schema.org unitCode, Peppol, EN 16931, EDIFACT, and GS1 already use, so LBR resolves to one meaning across a business, an agent, and a payment platform, rather than lb vs lbs vs pound drifting apart.
  • No maintenance burden: because the vocabulary lives in Rec 20 and not in a UCP enum, we never have to add, deprecate, or version unit values ourselves.
  • Readable and flexible: leaving the type as string avoids a constraining enum, and unit_text carries the human or locale friendly label so we do not lose display context.

@jingyli jingyli Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good callout! While it is consistent with other fields like language (IETF BCP 47) and currency (ISO 4217) where we follow a known standard, I have some thoughts on why I still prefer a free-form string more here:

  • With all the other standards, it is still very well-understood by everyday user. However, UN/CEFACT Rec 20 is not for common use at all (a quick research seems to indicate that this is the technical standard for supply chains/trades). This would present challenges for platform interpretation unless unit_text is supplied by businesses OR platform implements some translation logic between Rec 20 and everyday units (i.e. map C62 to count or each).
  • To minimize the risk you rightly called out on synonym drifts, I've added schema prose to this field's description to prefer (SHOULD) using standard symbols - the more common display I see in eCommerce (whether it's for grocery or other measurable goods).

@amithanda amithanda Jul 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack that Rec 20's everyday-interpretability is a fair concern and human-readable symbols are probably the right call.

What I think is missing is that UCP already has a house style for open strings with a known vocabulary and a long tail, and this field does not follow it yet. availability.status reads "Well-known values: in_stock, backorder, preorder, out_of_stock, discontinued." expectation.method_type reads "Well-known values: shipping, pickup, digital; additional values MAY be used." total.type enumerates subtotal, items_discount, discount, fulfillment, tax, fee, total and adds "Businesses MAY use additional values." policies[].type goes further with "Platforms MUST tolerate unknown values." The pattern is consistent across at least a dozen fields: enumerate the known values inline, then say what to do with the rest.

By comparison, "SHOULD prefer using standard symbol (e.g., kg for kilogram, lb for pound)" is illustrative rather than enumerative, and silent on unknowns, so lb and lbs, or floz and fl oz, remain equally compliant.

That matters because the unit is not just display text here, it qualifies numbers that get compared and summed. order_line_item.status is derived from quantity.fulfilled == quantity.total, total moves with signed adjustment quantities, and fulfilled accumulates from fulfillment_event quantities, so a consumer has to know whether two quantities are in the same unit before it can do any of that. To a machine, lb and lbs are two different units. The same applies to unit pricing, where measure.unit and reference.unit have to agree for "per 100ml" to render correctly. Left open, every platform writes its own synonym table for every business it integrates, and the protocol could settle it once instead.

The drift is not hypothetical. The count token alone is ct at Google, each at Instacart, and ITEM at Shopify. Worth noting Shopify's UnitPriceMeasurement mirrors variant.unit_price almost exactly (quantityValue and quantityUnit, referenceValue and referenceUnit), and it constrains both unit fields to a closed enum of 25 readable tokens like LB, KG, and OZ.

So would giving unit the same treatment? Concretely, enumerate the common units inline in the description for weight, volume, length, and area, keep the existing MUST for each, and add the usual clause that businesses MAY use additional values for long tail units like bunch or head. That stays a description change rather than a schema one, so it avoids the enum maintenance you flagged.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to document the common units, including those prescribed by various unit price regulations, for example those in NZ, AU and the EU. To illustrate, Google currently has a predefined list of 23 units.

@gsmith85 gsmith85 Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here’s a potential compromise proposal that bridges the gap between structured standardization and readability:

We could anchor our unit vocabulary on UNECE Rec 20 (Codes for Units of Measure), but instead of using the opaque alphanumeric codes (e.g., C62 or LBR), we use the id without the rec20: prefix.

For example:

  • "one" instead of "C62" (for rec20:one)
  • "kilogram" instead of "KGM" (for rec20:kilogram)
  • "pound" instead of "LBR" (for rec20:pound)
  • "fluid_ounce_(US)" instead of "OZA"

Why this works:

  1. Human & Agent Readability: Developers and AI shopping agents can inspect and generate values like "one", "pound", or "kilogram" naturally without translating (like mapping "C62""each").
  2. Grounded in an Established Standard: Rather than inventing custom enum values or leaving the field unconstrained, referencing UNECE Rec 20 IDs grounds UCP in an authoritative international standard.
  3. Improves Cross-Merchant Interoperability: Standardizing around Rec 20 IDs prevents synonym drift across merchants (count vs each vs ITEM vs ct), making quantity matching, unit pricing calculations, and post-order adjustments reliable across platforms.
  4. Follows UCP House Style: In the schema prose description for unit, we can document the common UNECE IDs (e.g., one, kilogram, pound, fluid_ounce_(US), ...) as well-known values while noting that businesses MAY use additional valid UNECE Rec 20 IDs for long-tail units.

@jingyli
jingyli requested a review from amithanda July 24, 2026 23:35
@amithanda

amithanda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Stepping back a bit to reconsider the problem and proposing another design option for modeling quantity.

The problem is that quantity is an integer today, so it cannot express weighed goods. Worth noting UCP also has no field anywhere that declares the unit an item is sold in: variant.unit_price.measure is packaging size and reference is a display denominator, so neither fills that role.

anyOf [integer, measure] solves the first part but places the unit inside every quantity, and the seven quantity fields on a line are arithmetically coupled. order_line_item.status is derived as "fulfilled if quantity.total > 0 and quantity.fulfilled == quantity.total", total moves with signed adjustment quantities, and fulfilled accumulates from fulfillment_event quantities. With a unit on each record, nothing stops fulfilled arriving as { "value": 32, "unit": "oz" } against a total of { "value": 2, "unit": "lb" }. Those are equal in reality and unequal as objects, so the derived status silently breaks.

Would declaring the selling unit once on item.json work better, with quantity widening from integer to number?

// countable, unchanged from today
{ "id": "li_1",
  "item": { "id": "sku_egg", "title": "Eggs, dozen", "price": 499 },
  "quantity": 2 }

// sold by weight, in 0.25 lb increments
{ "id": "li_2",
  "item": { "id": "sku_apple", "title": "Apples", "price": 299,
            "sold_by": { "unit": "lb", "step": 0.25 } },
  "quantity": 1.5 }

The reason this shape fits UCP specifically: item is embedded in both line_item and order_line_item, and expectation, fulfillment_event, and adjustment all reference a line by line_item_id and carry only { id, quantity }. So the unit is stated once per line and inherited by every dependent record.

{ "id": "li_2",
  "item": { "id": "sku_apple", "price": 299, "sold_by": { "unit": "lb", "step": 0.25 } },
  "quantity": { "original": 2, "total": 2, "fulfilled": 1.2 },
  "status": "partial" }

{ "id": "fe_3",  "type": "shipped", "line_items": [ { "id": "li_2", "quantity": 1.2  } ] }
{ "id": "adj_1", "type": "return",  "line_items": [ { "id": "li_2", "quantity": -0.5 } ] }

Two records on the same line cannot disagree about units, fulfilled == total stays a plain numeric comparison, and order_line_item no longer needs two parallel branches. It also gives item.price its missing unit: price is described as a unit price, but for a weighed good, price per pound and quantity in pounds only reconcile in totals if both refer to the same unit. Putting the unit inside quantity disambiguates the quantity and leaves the price undefined, whereas one field on item governs both.

On making sold_by an object now rather than a bare unit string: adding a granularity hint later should stay non-breaking, and an object gives that for free since new fields can be added to it, while converting a string into an object later would itself be breaking. step also answers the concern raised above about how a platform knows it cannot send 0.001 when the business sells in 0.01 increments, and it sits naturally beside the unit because both describe how the business sells the item rather than what was transacted. Defaulting step to 1 keeps countable items correct with no extra work. Instacart's third quantity type, variable weight (purchased as a unit but priced by weight), is another attribute that could slot in later without a break.

Migration looks contained: quantity: 2 stays valid so countable-only producers change nothing, integer to number is a widening, and the duplicated anyOf branches in order_line_item disappear.

For what it is worth, none of the closest analogs nest the unit inside the quantity. Instacart puts cost_unit (each, lb, kg) on the catalog product and lets it determine what an order line supplies, Square keeps quantity_unit beside quantity, and Amazon attaches unitOfMeasure to listPrice and netCost. Square and Amazon both treat an absent unit as eaches, and Instacart uses the literal each, which matches the prose already added here.

Two tradeoffs worth naming. The unit sits one level away from the number it qualifies, and an absent sold_by has to mean eaches. Also, since item narrows to id on requests, should sold_by be optional on request rather than omitted, so a platform can echo it when it wants explicit validation?

@igrigorik
igrigorik self-requested a review July 27, 2026 13:03
@jingyli jingyli changed the title feat!: Support fractional quantities and location context feat!: Support fractional quantities and unit measurement in lower funnel capabilities Jul 27, 2026
@alex-jansen

Copy link
Copy Markdown

Thinking about catalog lookup, would it make sense to differentiate even further between how a product is ordered vs how it is priced? For example, a consumer UI might want to display that apples are sold individually (in increments of 1 apple) instead of in increments of 0.25 LB, but are priced by the pound. Example:

  • The customer orders apples individually, e.g., sold_by = 1 CT
  • The customer pays by the pound, e.g. priced_by = 1.0 LB
  • To estimate pricing, the average weight of an apple is necessary, e.g., sold_by_size = 0.4 LB
  • The customer orders 3 apples, e.g., quantity = 3
  • Apples are priced at $1.99/LB, i.e., price = $1.99

The customer then gets an estimated checkout total of (3x1x0.4/1.0)*$1.99=$2.39 before the item is physically weighed by a picker in the store to determine the final checkout total when fulfilling the order.

"properties": {
"value": {
"type": "number",
"description": "Precise fractional quantity."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The description for value is a bit narrow since value also covers whole counts and signed adjustments.

Updating it to something like "The quantitative magnitude of the measurement (e.g., count, fractional amount, or signed adjustment quantity)" would be an improvement.

@amithanda

Copy link
Copy Markdown
Contributor

Great flag @alex-jansen on Variable-Weight Countable Goods ("Ordered by Count, Priced by Weight") which our design should address. I am elaborating a bit more and using an e2e example to consider how this may work.

Think of Honeycrisp apples, watermelons, steaks, or whole fish. As Alex noted, a consumer UI often wants to display that apples are sold individually (sold_by = 1 CT) rather than by weight, but are priced by the pound ($1.99 / lb), using an average apple weight (0.4 lb) to calculate an estimated checkout subtotal before physical store weighing.

The design Approach A (from PR#653: decimal number + explicit descriptors on item/variant) can handle this cleanly by separating sold_by (how you order) from priced_by (how you pay):

  • sold_by: Defines the ordering basis and stepper rules ({ "unit": "each", "step": 1 }).
  • priced_by: Defines the pricing basis and average weight per ordered unit ({ "unit": "lb", "nominal_size": 0.4 }).
  • price: Quoted per whole unit of priced_by.unit ($1.99/lb = 199).

Estimated Line Total = quantity (3) × nominal size (0.4 lb) × price ($1.99/lb) = $2.39


Worked Examples: Ordered by Count, Priced by Weight

1. Catalog / Cart Discovery (get_product)

The catalog variant advertises that apples are ordered by each, priced by lb, with an average apple weight of 0.4 lb:

{
  "id": "var_honeycrisp_apple",
  "title": "Honeycrisp Apple",
  "price": { "amount": 199, "currency": "USD" }, // $1.99 per lb
  "sold_by": {
    "unit": "each",
    "step": 1            // Shopper orders in whole apple counts (1, 2, 3)
  },
  "priced_by": {
    "unit": "lb",
    "nominal_size": 0.4  // 1 each = ~0.4 lb average weight
  }
}

2. Checkout / Authoritative Order Line Item (order_line_item)

The customer orders 3 apples. Using 3 × 0.4 lb × $1.99/lb, the Business returns an estimated line total of 239 ($2.39):

{
  "id": "li_apples",
  "item": {
    "id": "var_honeycrisp_apple",
    "title": "Honeycrisp Apple",
    "price": 199,
    "sold_by": { "unit": "each", "step": 1 },
    "priced_by": { "unit": "lb", "nominal_size": 0.4 }
  },
  // Quantity tracks physical apples ordered!
  "quantity": {
    "original": 3,
    "total": 3,
    "fulfilled": 0
  },
  "totals": [
    { "type": "subtotal", "amount": 239 }, // Estimated at checkout: $2.39
    { "type": "total", "amount": 239 }
  ],
  "status": "pending"
}

3. Fulfillment & Catch-Weight Price Reconciliation (adjustment)

When the store picker picks 3 apples, they weigh 1.28 lb on the store scale (slightly larger than the nominal 1.20 lb estimate).

  • Actual Weighed Price: 1.28 lb × $1.99/lb = $2.55 (an increase of +$0.16).
  • The customer ordered 3 physical apples and received 3 physical apples (quantity.fulfilled: 3, quantity.total: 3).
    • The financial variance is reconciled cleanly via a catch-weight price adjustment of +$0.16 (16 minor units) to reflect the true scale weight!
// Order line item after physical weighing
{
  "id": "li_apples",
  "item": {
    "id": "var_honeycrisp_apple",
    "price": 199,
    "sold_by": { "unit": "each", "step": 1 },
    "priced_by": { "unit": "lb", "nominal_size": 0.4 }
  },
  // Physical quantity stays exactly 3 apples (fulfilled == total holds natively)
  "quantity": {
    "original": 3,
    "total": 3,
    "fulfilled": 3
  },
  "totals": [
    { "type": "subtotal", "amount": 255 }, // Reconciled to actual scale weight: $2.55
    { "type": "total", "amount": 255 }
  ],
  "adjustments": [
    {
      "id": "adj_cw_1",
      "type": "price_adjustment",
      "amount": { "amount": 16, "currency": "USD" },
      "description": "Catch-weight adjustment: 3 apples weighed 1.28 lb (est. 1.20 lb)"
    }
  ],
  "status": "fulfilled"
}

Why I think it might be more difficult to model using Single Integer Step Count (Approach B in PR# 653)

In Approach B, because there is only a single unit denominator (quantity_unit), a Business selling variable-weight countable goods (like Honeycrisp apples priced at $1.99/lb) is trapped in a dilemma:

  • If they set quantity_unit: LBR to preserve the legal transactional price per pound ($1.99/lb), a shopper ordering 3 apples is forced to order "quantity": 120 (1.20 lb). When the picker picks 3 apples weighing 1.28 lb (128), Approach B forces quantity.total to be 120 and fulfilled to be 128—conflating apple count with apple weight and breaking line item completion.
  • If they instead set quantity_unit: C62 (each) to preserve apple count (3 == 3), they lose the transactional contract for price per pound ($1.99/lb), forcing them to advertise an estimated price-per-apple instead.
    By cleanly separating sold_by (how you order: 3 apples) from priced_by (how you pay: $1.99/lb at 0.4 lb nominal size), our design preserves both the transactional price per pound AND physical quantity equality (3 == 3), while handling catch-weight financial reconciliation where it belongs: in the price adjustment.

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

Labels

gov:needs-tc-review schema PR changing the UCP schema status:under-review TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants