Skip to content

feat!: update Order capability#254

Open
richmolj wants to merge 7 commits intomainfrom
lr/order-spec-updates
Open

feat!: update Order capability#254
richmolj wants to merge 7 commits intomainfrom
lr/order-spec-updates

Conversation

@richmolj
Copy link
Contributor

@richmolj richmolj commented Mar 11, 2026

Description

Collection of updates to the Order capability to make the spec more flexible, robust, and clear.

  • Soften "append-only" on adjustments and "immutable" on line items
  • Remove "derived from events" quantity language
  • Model Order:Checkout as 1:N via checkouts array (order edits create new sessions)
  • Add signed quantities/amounts on adjustments and quantity.original on line items
  • Rename adjustment amount to totals for consistency with Order and OrderLineItem

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing
    functionality to not work as expected, including removal of schema files
    or fields
    )
  • Documentation update

Is this a Breaking Change or Removal?

  • I have added ! to my PR title (e.g., feat!: remove field).
  • I have added justification below.

Breaking Changes / Removal Justification

  • checkout_id (string) replaced with checkouts (array of { id, created_at } objects) — orders can reference multiple checkout sessions via edits and exchanges
  • amount on Adjustment replaced with totals (array of Total objects) — aligns with the pattern used by Order and OrderLineItem
  • minimum: 1 removed from adjustment line item quantities — signed values needed for returns and exchanges
  • minimum: 0 removed from amount.json — amounts can be negative for refunds, credits, and adjustment totals across all capabilities

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

@richmolj richmolj requested review from a team as code owners March 11, 2026 14:20
@igrigorik igrigorik added the TC review Ready for TC review label Mar 11, 2026
"properties": {
"id": {
"type": "string",
"description": "Checkout session identifier."
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
"description": "Checkout session identifier."
"description": ""Unique identifier of the checkout session."

To be consistent with checkout docs / schema desc

"type": "string",
"format": "date-time",
"description": "RFC 3339 timestamp when this checkout session was created."
}
Copy link
Contributor

Choose a reason for hiding this comment

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

is the purpose of created_at to identify the sequential order of multiple checkouts? If so, do we need to include updated_at as well?

Copy link
Contributor

Choose a reason for hiding this comment

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

Or is the purpose here just to distinguish the original checkout and edit/exchange checkouts ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

created_at is there so the sequence is clear. Checkouts are frozen after creation - they are never updated - so updated_at is not needed here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Should we just rely on order here instead? If we really want to add created_at, it feels like we should add it to checkout too, so there is no information you get about the checkout on order you could not get directly.

Copy link

@gsmith85 gsmith85 Mar 19, 2026

Choose a reason for hiding this comment

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

I agree with @lemonmade here on the checkouts array structure feeling askew, I'll attempt to summarize my concerns:

  1. Denormalization: Including created_at in the array items feels leaky. If the checkout resource is the source of truth, we should rely on a timestamp within.
  2. Ordering Contract: I understand the path to including created_at to imply an ordering but, even with it, there is no actual schema enforcement. If the client really cares about ordering they should double-check it, this is not expensive even if it's a 1:1000 relationship.
  3. Naming: created_at is misleading here. I assume we should only be referencing checkouts that reached a terminal (success) state. Should this be finalized_at or executed_at instead?
  4. Dereferencing: I'm not persuaded that we want to include the full checkout object rather than just references. An ordered array of checkout IDs provides the same lineage, and optionality to the client to fetch the full objects (or not).

"totals": {
"type": "array",
"items": {
"$ref": "total.json"
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think the current total and amount schema allows negative - should update them as part of this PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Removed minimum: 0 from amount.json so negative values are allowed for signed adjustment totals.

**Line Items** — what was purchased at checkout:

* Includes current quantity counts (total, fulfilled)
* Can change post-order (e.g. order edits, exchanges)
Copy link
Contributor

Choose a reason for hiding this comment

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

With order edits now in the picture, can we add a guideline around what line_items SHOULD include post these changes, is it:

  1. A comprehensive list of all the items that once existed in the order, even if they were altered/removed via an edit?
    OR
  2. Only contain remaining items that are present after the latest edit?

My hunch is that 1) is more comprehensive from a data/audit perspective given there may have been adjustments that reference back to these altered products?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Option 1 - comprehensive list. Line items should include all items that ever existed on the order, even if altered/removed via an edit. This is why quantity.original exists on line items - it preserves what was originally ordered at checkout, even when quantity.total changes to 0 after an edit. Adjustments can then always reference back to these items.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks, makes sense, is it possible to actually add this on top of this bullet here?

i.e. Something along the lines of ; **MUST** include all line items that ever existed on the order regardless of edits or alterations?

```json
{
"total": 3, // Current total quantity
"original": 3, // Quantity at checkout
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Maybe we should clarify a bit further that this is the checkout with the oldest created_at timestamp (and not just any checkout)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point. This refers to the original checkout (the one with the earliest created_at). Updated the description to clarify.

"partial",
"fulfilled"
],
"description": "Derived status: fulfilled if quantity.fulfilled == quantity.total, partial if quantity.fulfilled > 0, otherwise processing."
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we will need some more status or at least add some more conditions to this described logic to cover the following 2 scenarios:

  1. If order is cancelled prior to fulfillment, then by this logic today, item-level status will be stuck in processing. We should probably add some terminal state to catch cancelled.
  2. If an item is fulfilled, then edited from an exchange, then I'd imagine the old item that got exchanged would have totals = 0 but fulfilled > 0, in that case, what status should it be in (maybe we should have fulfilled if quantity.fulfilled >= quantity.total instead)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good observations. The status derivation documented here covers the common case - it is not meant to be exhaustive. The spec treats status as an open string, so businesses can use additional values like cancelled or exchanged for these scenarios. We will keep the derivation simple for now and can expand the documented examples in a follow-up if needed. Does that sound good?

Copy link
Contributor

Choose a reason for hiding this comment

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

The explanation makes sense, the only nit-pick from me would be if we can represent this "open string is accepted" concept properly here? My understanding of JSON schema is that as soon as we have enum key, it restricts the value to a finite fixed value set where no further extension is accepted (which is the case here).

@richmolj
Copy link
Contributor Author

@alexpark20 @jingyli Thanks for the thorough review! Responded to each comment inline and pushed updates for the ones that needed code changes. Please take another look when you get a chance.

@richmolj richmolj requested review from alexpark20 and jingyli March 13, 2026 16:36
Copy link
Contributor

@igrigorik igrigorik left a comment

Choose a reason for hiding this comment

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

See comment on removing "minimum": 0 on amount.json.

"title": "Amount",
"description": "Monetary amount in the currency's minor unit as defined by ISO 4217. Refer to the currency's exponent to determine minor-to-major ratio (e.g., 2 for USD, 0 for JPY, 3 for KWD).",
"type": "integer",
"minimum": 0
Copy link
Contributor

Choose a reason for hiding this comment

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

@richmolj I think this is a footgun. I understand the motivation, but removing this constraint on shared type opens up a class of unexpected scenarios — e.g. negative prices on line items, etc.

If we move forward with allowing negative values, I think we need to be more surgical. PTAL @ #261 (commits), we can use similar approach here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, I've updated the PR with signed_amounts.json.

That said, I disagree. The current field requires mental math to understand type = discount means negative, why not make this explicit? Negative line item prices, or negatives from other business-specific types, should be up to the business logic not the spec.

I don't feel strongly here, though, and it's a more significant breaking change. So went ahead and updated.

richmolj added a commit that referenced this pull request Mar 16, 2026
…emoving minimum on shared amount

Restore minimum: 0 on amount.json and introduce signed_amount.json
for places that genuinely need negative values. Adjustment totals now
inline their schema with signed_amount refs, keeping total.json and
all other amount.json consumers non-negative.

Addresses review feedback from @igrigorik on #254.
…emoving minimum on shared amount

Restore minimum: 0 on amount.json and introduce signed_amount.json
for places that genuinely need negative values. Adjustment totals now
inline their schema with signed_amount refs, keeping total.json and
all other amount.json consumers non-negative.

Addresses review feedback from @igrigorik on #254.
@richmolj richmolj force-pushed the lr/order-spec-updates branch from 0983c3b to 66f6dbf Compare March 16, 2026 13:31
@richmolj richmolj requested a review from igrigorik March 16, 2026 13:32
…unt override

Align with the composition pattern in PR #261 (totals contract):
allOf total.json base + signed_amount.json override, rather than
inlining the schema.
Copy link
Contributor

@lemonmade lemonmade left a comment

Choose a reason for hiding this comment

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

Left a few nits, but the main parts of the change are looking good to me 👍

"type": "string",
"format": "date-time",
"description": "RFC 3339 timestamp when this checkout session was created."
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we just rely on order here instead? If we really want to add created_at, it feels like we should add it to checkout too, so there is no information you get about the checkout on order you could not get directly.

"properties": {
"original": {
"type": "integer",
"minimum": 0,
Copy link
Contributor

Choose a reason for hiding this comment

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

Could all of the properties in here use the https://ucp.dev/schemas/shopping/types/amount.json shared type?

"title": "Signed Amount",
"description": "Monetary amount in the currency's minor unit as defined by ISO 4217. Refer to the currency's exponent to determine minor-to-major ratio (e.g., 2 for USD, 0 for JPY, 3 for KWD). May be negative — the sign is intrinsic to the value (e.g., discounts are negative, charges are positive).",
"type": "integer"
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is in main now, a rebase should remove the diff.

Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like we haven't run this in quite awhile, I am not even sure why it is still here. Is there a reason you had to update it for this PR?

@lemonmade lemonmade added this to the Working Draft milestone Mar 18, 2026
Copy link
Contributor

@jingyli jingyli left a comment

Choose a reason for hiding this comment

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

Thanks for the responses, resolved some threads and added one more generic question on how we think about the representation of checkout_id within order capability.

**Line Items** — what was purchased at checkout:

* Includes current quantity counts (total, fulfilled)
* Can change post-order (e.g. order edits, exchanges)
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks, makes sense, is it possible to actually add this on top of this bullet here?

i.e. Something along the lines of ; **MUST** include all line items that ever existed on the order regardless of edits or alterations?

"checkout_id": {
"type": "string",
"description": "Associated checkout ID for reconciliation."
"checkouts": {
Copy link
Contributor

Choose a reason for hiding this comment

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

Looking at this a bit more, a naive QQ: What do we feel is the value by having this full array (especially as a required field)?

The reason I question this is 2-fold:

  1. This seems to suggest an additional dependency between UCP's checkout & order capabilities. What does it mean for a platform (like a post-order management agent) that only implements/supports order capability in UCP? To them, the concept of checkout_id is fairly useless since they don't really need/require knowledge on the upstream process.
  2. Per this guideline, there is no guarantee that business will persist the checkout session after completion so I'm unsure what can the platform truly do with this full list of checkout_ids (having the initial one makes sense to me for the reconciliation use case). It also feels a bit odd conceptually since we don't generally go back from order to checkout.

"partial",
"fulfilled"
],
"description": "Derived status: fulfilled if quantity.fulfilled == quantity.total, partial if quantity.fulfilled > 0, otherwise processing."
Copy link
Contributor

Choose a reason for hiding this comment

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

The explanation makes sense, the only nit-pick from me would be if we can represent this "open string is accepted" concept properly here? My understanding of JSON schema is that as soon as we have enum key, it restricts the value to a finite fixed value set where no further extension is accepted (which is the case here).

@gsmith85
Copy link

gsmith85 commented Mar 19, 2026

I think it may be work escalating this one to the TC, I think the change risks introducing a domain mismatch introduced by using the checkouts array to handle post-purchase modifications.

  • Incongruous Properties: A Checkout is a session-based negotiation primitive; an exchange is a persistent accounting entry. Shunting the latter into a Checkout forces a tool built for point-in-time discovery to perform ledger reconciliation logic. This causes properties to drift semantically; for example, total shifts from representing gross intent (the absolute cost of items) to a delta (the balance of a swap). This can break standard validation logic, where a checkout engine expects a positive total and a payment method, whereas an exchange accounting session may result in a zero or negative balance.
  • Functional Divergence: Even if a validation session is modeled with a Checkout, the use cases are fundamentally different. For an exchange, the Checkout is a point-check for cost and availability. Forcing the Order to act as a permanent folder for these IDs creates a dependency on transient reconciliation data that may not have the same lifecycle as the primary record.
  • Complexity: The proposed change to Orders adds complexity to the object and erodes desirable properties like the append-only log for modifications. It forces clients to re-calculate the current truth by replaying a list of session intents rather than following a clean log of finalized adjustments.

I would propose, we continue to lean on the Adjustments capability (which already supports signed quantities/amounts) to handle edits, returns, and exchanges. This keeps the Order fulfillment-centric. If we must link to a validation session for audit purposes, we should do so via a source_checkout_id reference on the Adjustment itself, rather than at the Order root.

@richmolj
Copy link
Contributor Author

@gsmith85 Thanks for the feedback. I think this is simpler than it appears.

Consider a concrete scenario: a buyer places an order through UCP, then calls the merchant and asks to add an item. The merchant sends the buyer a link where they go through checkout again — this time to pay for the additional item and add it to the existing order. The buyer literally goes through checkout twice, so the order references two checkout sessions. That's all the checkouts array records. The buyer is going through checkout — selecting items, approving a total, entering payment. How a platform calculates totals within an edit checkout is an implementation choice, not a spec concern.

The checkouts array is a reference list for audit, not something you replay. The order's line_items and adjustments remain the source of truth for current state — adjustments already support returns, exchanges, and other post-order money movements. This is specifically about recording that the buyer went through checkout again.

This is all entirely optional, by the way. If the merchant can handle the edit themselves without the buyer re-entering checkout, they model it as a single checkout with an adjustment. Both are valid — the spec just doesn't preclude the scenario where the buyer goes through checkout again.

Happy to escalate to the TC if you'd like further discussion.

@gsmith85
Copy link

@richmolj, thanks for the concrete example, the add-on scenario makes the intent much clearer! I think where we're diverging is just how to best translate that type of flow into the base schema.

In the example you give, the tension lives between:

  • An Order is explicitly defined as the result of a checkout submission (order.md).
  • For an add-on, the buyer goes through a net-new checkout with new tax and payment liabilities.

Even though the add-on was in reference to a previous Checkout:Order pair, I think that the second flow should result in a second order. Otherwise, the risk is that we conflate presentation/fulfillment grouping concern with our base entity relationships, effectively turning the Order back into an open pseudo-shopping-cart.

Is there a reason to not model add-ons as a new Order at the protocol layer, and let platforms handle linking them? Perhaps we're missing a layer of modeling around Order relationships (e.g. a FulfillmentGroup for logistics) that would be valuable in representing the relationship explicitly?

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.

6 participants