Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions source/schemas/common/types/measure.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ucp.dev/schemas/common/types/measure.json",
"title": "Measure",
"description": "Represents a quantitative value paired with its physical or standardized unit of measurement.",
"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.

"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.

},
"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.

"type": "string",
"description": "Unit of measurement. MUST use 'each' to represent countable goods and SHOULD prefer using standard symbol (e.g., kg for kilogram, lb for pound) for other units."
}
}
}
12 changes: 10 additions & 2 deletions source/schemas/shopping/types/adjustment.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,16 @@
"description": "Line item ID reference."
},
"quantity": {
"type": "integer",
"description": "Signed quantity affected by this adjustment. Negative values represent reductions (e.g. returns); positive values represent additions (e.g. exchanges)."
"anyOf": [
{
"type": "integer",
"description": "Signed quantity affected by this adjustment. Negative values represent reductions (e.g. returns); positive values represent additions (e.g. exchanges)."
},
{
"$ref": "../../common/types/measure.json",
"description": "The precise signed fractional quantity and unit of the item affected by this adjustment. Negative values represent reductions (e.g. returns); positive values represent additions (e.g. exchanges)."
}
]
}
}
},
Expand Down
17 changes: 14 additions & 3 deletions source/schemas/shopping/types/expectation.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,20 @@
"description": "Line item ID reference."
},
"quantity": {
"type": "integer",
"minimum": 1,
"description": "Quantity of this item in this expectation."
"anyOf": [
{
"type": "integer",
"minimum": 1,
"description": "Quantity of this item in this expectation."
},
{
"$ref": "../../common/types/measure.json",
"properties": {
"value": { "exclusiveMinimum": 0 }
},
"description": "The precise fractional quantity and unit of the item in this expectation."
}
]
}
}
},
Expand Down
17 changes: 14 additions & 3 deletions source/schemas/shopping/types/fulfillment_event.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,20 @@
"description": "Line item ID reference."
},
"quantity": {
"type": "integer",
"minimum": 1,
"description": "Quantity fulfilled in this event."
"anyOf": [
{
"type": "integer",
"minimum": 1,
"description": "Quantity fulfilled in this event."
},
{
"$ref": "../../common/types/measure.json",
"properties": {
"value": { "exclusiveMinimum": 0 }
},
"description": "The precise fractional quantity and unit of the item in this event."
}
]
}
}
},
Expand Down
17 changes: 14 additions & 3 deletions source/schemas/shopping/types/line_item.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,20 @@
"$ref": "item.json"
},
"quantity": {
"type": "integer",
"description": "Quantity of the item being purchased.",
"minimum": 1
"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."
}
]
},
"totals": {
"type": "array",
Expand Down
68 changes: 50 additions & 18 deletions source/schemas/shopping/types/order_line_item.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,58 @@
"description": "Product data (id, title, price, image_url)."
},
"quantity": {
"type": "object",
"required": ["total", "fulfilled"],
"properties": {
"original": {
"type": "integer",
"minimum": 0,
"description": "Quantity from the original checkout."
"anyOf": [
{
"type": "object",
"required": ["total", "fulfilled"],
"properties": {
"original": {
"type": "integer",
"minimum": 0,
"description": "Quantity from the original checkout."
},
"total": {
"type": "integer",
"minimum": 0,
"description": "Current total active quantity. May differ from original due to post-order modifications (e.g., returns or cancellations)."
},
"fulfilled": {
"type": "integer",
"minimum": 0,
"description": "Quantity fulfilled so far."
}
},
"description": "Quantity tracking for the line item."
},
"total": {
"type": "integer",
"minimum": 0,
"description": "Current total active quantity. May differ from original due to post-order modifications (e.g., returns or cancellations)."
},
"fulfilled": {
"type": "integer",
"minimum": 0,
"description": "Quantity fulfilled so far."
{
"type": "object",
"required": ["total", "fulfilled"],
"properties": {
"original": {
"$ref": "../../common/types/measure.json",
"properties": {
"value": { "minimum": 0 }
},
"description": "The fractional quantity and unit of the item in the original checkout."
},
"total": {
"$ref": "../../common/types/measure.json",
"properties": {
"value": { "minimum": 0 }
},
"description": "Current total active fractional quantity. May differ from original due to post-order modifications (e.g., returns or cancellations)."
},
"fulfilled": {
"$ref": "../../common/types/measure.json",
"properties": {
"value": { "minimum": 0 }
},
"description": "Fractional quantity fulfilled so far."
}
},
"description": "Precise fractional tracking for the line item."
}
},
"description": "Quantity tracking for the line item."
]
},
"totals": {
"type": "array",
Expand Down
2 changes: 1 addition & 1 deletion source/schemas/shopping/types/product.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
"items": {
"type": "string"
},
"description": "Product tags for categorization and search."
"description": "Product tags for categorization, search, and compliance rules (e.g., restricted goods)."
},
"metadata": {
"type": "object",
Expand Down
9 changes: 2 additions & 7 deletions source/schemas/shopping/types/variant.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,8 @@
"description": "ISO 4217 currency code."
},
"measure": {
"type": "object",
"description": "Product quantity in packaging (e.g., 750ml bottle).",
"required": ["value", "unit"],
"properties": {
"value": { "type": "number", "description": "Package quantity." },
"unit": { "type": "string", "description": "Unit of measurement." }
}
"$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.

"description": "Product quantity in packaging (e.g., 750ml bottle)."
},
"reference": {
"type": "object",
Expand Down
Loading