Bundles, Components and Five Fulfillment States: What UCP #976 and #979 Mean for Order Sync
Most agentic commerce writing stops at the checkout. The parts that break order sync come later: a kit that the warehouse ships as three parcels, or a carrier exception your 3PL calls EXC_04. Two pull requests opened in the Universal Commerce Protocol repository during the week of October 19, 2026 deal with exactly these parts:
- PR #976, “docs(checkout): define bundle semantics for nested line items” gives
parent_ida meaning. - PR #979, “feat(order)!: standardize fulfillment event types” turns
fulfillment.events[].typeinto an open string with five well-known values. It implements proposal #729.
Both are proposals. As of October 23, 2026, both are open and unmerged, neither has a review yet, and the “Governance / Approvals” check on each reports pending required approvals. #976 is labeled status:needs-triage. #979 is labeled status:under-review and gov:needs-tc-review, which means it needs Technical Council approval. Every rule quoted below could change before it reaches a release.
As in After the Buy Button and What a Commerce Webhook Must Guarantee, I keep what the PR says apart from my inference. Field names and MUST/SHOULD rules are quoted from the diffs. When I recommend something, I label it as inference.
What #976 actually says about bundles
parent_id is already on both line_item.json and order_line_item.json on main. Its current description is one line: “Parent line item identifier for any nested structures.” The PR adds no new fields. It defines what the existing field means. From the diff:
- Flat list, one level.
line_items[]stays flat. A line withparent_idis a component of the line with thatid, and together they “form a bundle — one purchasable unit, priced as a whole, composed of individually identifiable products.”parent_idMUST reference a different line in the same array. A line withparent_idMUST NOT itself be referenced as a parent. - Quantity is total, not per unit. “A component’s
quantityis the total quantity across all units of the parent bundle.” In the PR’s example, raising the kit to quantity 2 makes each component quantity 2. - Breakdown, not addition. “Components describe what the buyer receives, not what the buyer additionally pays.” Component
totals[]are “a breakdown of the parent’stotals[], never additive to them.” - One invariant. “The sum of component
totals[type=total].amountvalues MUST equal the bundle parent’stotals[type=subtotal].amount.” - Aggregates skip components. Lines with
parent_id“MUST be excluded fromsubtotal, fromitems_discount, and from any platform verification sum; including them double-counts the bundle.” The Discount extension’sitems_discountinvariant gets the same qualifier. - The business owns the structure. Platforms send parent lines only. Businesses “MUST ignore, and MUST NOT reject” echoed components, and are “authoritative for component membership, structure, and pricing.” A bundle can be formed by expand (the buyer adds a “Coffee Starter Kit” and the business breaks it out) or by merge (the buyer adds a mug and beans and the business recognizes a kit).
- Bundle is the default, not the only meaning. “
parent_idestablishes containment without asserting the nature of the relationship.” Add-ons such as a warranty on a TV are explicitly out of scope. The PR description suggests a futureparent_typediscriminator in an extension for those.
Most of the text sits in the Checkout spec. The Order spec’s prose is not changed, but the order_line_item.json description is: “order aggregate totals count lines without parent_id only.” The PR is titled docs(...), not marked breaking. I would still treat it as a semantic break for any consumer that currently sums every line (inference).
Read the example closely
The PR’s example has more in it than it first appears:
| Line | item.price | subtotal | items_discount | total |
|---|---|---|---|---|
li_kit (parent) | 5300 | 5300 | −500 (“Bundle Savings”) | 4800 |
li_mug (component) | 2000 | 2000 | −200 | 1800 |
li_beans (component) | 3500 | 3500 | — | 3500 |
| Checkout totals | 5300 | −500 | 4800 |
Three things matter for an ERP:
- Component list prices do not add up to the parent price. 2000 + 3500 = 5500, while the kit is 5300.
item.priceon a component is not an allocation. The component’stotalis. - Components break down the parent’s subtotal, not its total. 1800 + 3500 = 5300. The parent’s 500 bundle discount is not pushed down to the components. If your ERP needs net revenue per component, you still have to allocate that 500 yourself (inference, but it follows from the invariant).
- Component discounts disappear from checkout-level
items_discount. The mug’s −200 is already inside the parent’s subtotal, so the checkout shows −500, not −700. If you rebuild discount reporting by summing every line’sitems_discount, you get the wrong answer.
Mapping parent and component lines to the ERP
ERPs model “one sellable thing made of other things” in different ways. In generic terms (my mapping, not the PR’s):
- Kit / sales BOM / item group, exploded at order time. The header has no stock of its own. Components are picked and shipped and carry the inventory. This is the closest match to a UCP bundle: parent maps to the kit header line, components map to component lines.
- Assembly / manufactured or pre-built item. The finished SKU is stocked and picked as one unit, and the components are consumed upstream. The PR does not tell you whether to expose components here. Because the business is authoritative for composition, you can choose to send the assembly as a plain line with no components (inference). Sending components you will never pick separately invites the platform to show per-component tracking you cannot provide.
- Merchandising bundle (price grouping only). Separate SKUs that happen to get a joint price. The PR’s merge direction fits this case. Your ERP may have no header at all, so you need a synthetic header or a promotion record to hold the parent’s id and discount (inference).
Rules I would apply on import (inference):
- Persist the UCP
parent_idrelationship verbatim, alongside your own kit or BOM reference. Do not rely on the ERP to re-explode the kit and produce the same components. A BOM revision between checkout and order import changes the composition, while the buyer was priced on the business’s response. - Validate the invariant on ingest: for each parent, the sum of component
totalequals the parentsubtotal, in minor units, exactly. If it fails, quarantine the order rather than “fixing” it. - Store the component-to-parent ratio as
component.quantity / parent.quantity. The PR defines component quantity as a total across units, so you have to derive the per-unit ratio, and you need it for partial shipments and returns. - Post revenue and tax on one level only: either the parent or the components, never both. This is the same double-count the PR warns about for aggregates, now on the ledger side.
Allocation: refunds, tax and rounding
The PR defines one allocation: component totals add up to the parent subtotal. Everything after that is yours.
Bundle-level discount. Allocate the parent’s own discount to components in proportion to their total amounts, using a largest-remainder method so that the parts add up exactly. With the example: 500 × 1800/5300 = 169.81 and 500 × 3500/5300 = 330.19. Rounding gives 170 and 330, which sum to 500. Net component values are then 1630 and 3170, which sum to 4800. (The method is inference. The arithmetic follows from the example.)
Per-unit rounding. Because component quantities are totals across bundle units, a quantity-3 bundle can produce component totals that do not divide evenly by 3. Keep a per-unit allocation schedule when you import the order (for example, a component total of 1700 across three units becomes 567, 567 and 566), not just a line total. Otherwise the refund for “one unit of the mug” depends on which unit you decide was returned. In the PR’s quantity-2 example everything divides evenly, so the example does not show this problem.
Tax. The PR says nothing about tax on components. Its examples carry only subtotal, items_discount and total on lines. The reason this matters (inference, and not tax advice): bundles often combine items with different tax treatment, such as beans and a mug, and in many jurisdictions the split of the bundle price drives the tax. If the business computes tax on an allocation, that allocation has to be the one your ERP posts. The safest assumption is that the business’s component total values are the tax basis. Where they are not, you need the business’s tax engine output, not a re-computation.
Refunds. Adjustments already exist on main: type is an open string (refund, return, …), line_items[] holds signed quantities, and totals[] holds signed amounts. Nothing in #976 says whether an adjustment may or must reference a component line rather than the parent. My inference: reference the component when one component comes back, and refund that component’s net allocated amount (its total minus its share of the bundle discount, plus its tax). Then check the result against your returns policy, because many merchants do not allow partial kit returns or re-price the remainder when one comes back.
Component-level picking and partial shipment
Fulfillment events reference line_items[] by id and quantity. Components are line items with ids, so a warehouse that ships the mug today and the beans next week can, as far as I can tell, emit events against each component separately. The PR does not say this explicitly (inference).
What the PRs do not settle is the parent’s quantity.fulfilled and derived status (processing, partial, fulfilled, removed). If one of two components has shipped, has the bundle been fulfilled at all? My suggestion: compute the parent’s fulfilled count as the number of complete bundle units, which is the minimum across components of floor(component.fulfilled / ratio). Then document that choice in your integration contract. A business that instead marks the parent fulfilled when the first box leaves will produce a platform display that contradicts the parcel tracking.
What #979 actually says about fulfillment events
From the diff of fulfillment_event.json and the Order spec:
| Value | Normative meaning (quoted, abridged) |
|---|---|
processing | The Business is preparing the goods or package before handoff to a carrier or delivery network. |
shipped | The goods have left the Business’s control, normally through handoff. “A carrier-network movement scan is not required.” |
in_transit | The carrier or delivery network “has confirmed that the package is moving.” |
delivered | Delivered to the buyer or the agreed delivery location. |
canceled | This fulfillment was canceled and “is not expected to complete. This does not necessarily mean that the entire Order was canceled.” |
The surrounding rules:
typestays an open string and “MUST NOT be represented as a closed enum in generated SDKs.”- Businesses MUST use the exact lowercase
snake_casevalue when the semantics match. “A custom value MUST NOT rename a well-known semantic”:finishedmeaning delivered must bedelivered, “even if adescriptionis provided.” - Custom values (
failed_attempt,undeliverable,returned_to_senderare given as examples) MUST carry a “non-empty, consumer-understandabledescription.” The schema enforces this with anif/thenon types outside the five values and addsminLength: 1todescription. The PR notes that whitespace alone passesminLengthbut does not meet the rule. - An explicitly empty
descriptionis invalid even on a well-known type. Well-known types can omit it, and if present it MUST NOT change the meaning. - Creating a shipping label “does not, by itself, mean
shipped.” - Platforms MUST accept the five values, MUST NOT reject an Order solely for an unknown type, MUST use
descriptionas the fallback, and MUST treat it as untrusted text. - Platforms “MUST NOT infer a single global linear Order status from the latest event alone.” UCP defines no transition graph and no order-level fulfillment status.
line_items[].statusis quantity-derived and not interchangeable with events: “status: "fulfilled"does not necessarily mean that the buyer has received the goods.” Platforms “MUST NOT sum event quantities to derivequantity.fulfilled.”- Migration: synonyms MUST move to the standard value. And because Order responses are full snapshots, “Businesses migrating historical events MUST ensure the full snapshot conforms to the schema version they advertise, including descriptions for historical custom events.”
The PR marks itself BREAKING CHANGE. For a producer, old custom events without a description become invalid.
Mapping OMS, WMS and 3PL statuses to five values
Back-office systems typically track more than five states. The table below is my mapping, not the PR’s. Every row that is not a direct quote from the definitions above is inference.
| Typical OMS / WMS / 3PL status | UCP event type | Notes |
|---|---|---|
| Released to warehouse, allocated, picking, packed | processing | Goods still in the business’s control. |
| Label created, awaiting pickup | processing | The PR says label creation alone is not shipped. |
| Manifested, handed to carrier, left the dock | shipped | No carrier scan required. |
| First carrier scan, in network | in_transit | Requires carrier confirmation. |
| Out for delivery | in_transit + description | Inference: a stage of in-network movement, so use the standard value with detail, not a custom synonym. |
| Delivered, picked up by buyer at agreed location | delivered | Whether store pickup counts as “agreed delivery location” is my reading. |
| Shipment or fulfillment order canceled, short-ship cancel | canceled | Scoped to this fulfillment, not the order. |
| Partially shipped | — | Not a type. Emit shipped for the shipped quantities. Line status becomes partial from quantities. |
| Backordered | — or custom | Not a physical fulfillment fact yet. Inference: update the expectation (fulfillable_on, description) instead of inventing an event. |
| Delivery exception, delay, failed attempt | custom (e.g. failed_attempt) + description | The PR’s own custom example. |
| Returned to sender, undeliverable, lost | custom + description | No standard value. |
| Customer return (RMA) | — | Not a fulfillment event. Use an Adjustment (return, refund), which already exists on main. |
What the five values lose: partial shipment (carried by quantities instead), backorder, exceptions, and returns. That is a deliberate choice. The proposal’s non-goals include “Standardizing every carrier scan, delivery exception, or return reason.” The practical result is that your exception handling still depends on custom types and their free-text descriptions.
Degrading custom types, in both directions
Producing (inference): if one of the five definitions is true of the event, you MUST use it, and the nuance goes into description. Only emit a custom type when none of the five is true. “Out for delivery” is in_transit. “Delivery attempted, nobody home” is not one of the five, so it is custom.
Consuming (inference): an unknown type should not move your internal state machine. Store it as an annotation on the affected quantities, show its description (escaped), and route it to an exception queue if it matches patterns you care about. If you must bucket it for reporting, map it to an “exception” bucket of your own rather than to the nearest standard state. Mapping failed_attempt to in_transit hides exactly the cases a customer service team needs to see.
Idempotency and ordering of events
The event schema already requires id, occurred_at, type and line_items, and events are append-only. Order webhooks on main carry the full order snapshot, with Webhook-Id stable across retries (see What a Commerce Webhook Must Guarantee).
What I would do on the receiving end (inference):
- Dedupe the delivery on
Webhook-Id, then dedupe events on(order id, event id). Each snapshot repeats the full history, so “new events” means event ids you have not stored yet. - Track state per line item and per quantity, not per order. The PR’s split-shipment example shows a
deliveredevent followed by a laterprocessingevent on the same line for the other unit, and states that the later one “does not undo the earlier delivery.” - Order by
occurred_atwithin a scope, but expect late arrivals. Carriers backfill scans. Anin_transitthat arrives afterdeliveredfor the same quantities is history, not a regression. Keep transitions forward-only, as in the poller from Shop MCP Without Webhooks. - Take
quantity.fulfilledfrom the line item. Never add up event quantities. The PR forbids it, because successive events describe the same units. - Do not trigger invoicing or revenue recognition from
status: "fulfilled"and assume delivery, or fromdeliveredand assume the full line. Pick one signal per ERP posting and write it down. The ERP sync boundary argument applies here too.
Migrating existing custom values
For a business already emitting events (inference on the method, rules from the PR):
- Inventory every distinct
typeyou have ever emitted, including historical events that still appear in order snapshots. - Classify each one as a synonym of a well-known value (
finished,complete,cancelled,IN_TRANSIT,in-transit,label_created→processing) or genuinely custom. - Translate at the serialization boundary rather than rewriting your WMS data. Keep the raw carrier or WMS code internally and map it when you build the UCP snapshot. This also handles the requirement that historical events conform.
- Write a consumer-readable
descriptionfor every custom type, with no internal codes, and nothing that is unsafe to render. - Validate full snapshots against the new schema in CI, including the conditional
descriptionrule, before you advertise the new version. - Watch for any check in your own code that treats
typeas a closed enum. The PR forbids closed enums in generated SDKs, and the same reasoning applies to your parser.
Where the two PRs meet
Neither PR mentions the other. A bundle shipped in parts is where they collide: component events, a parent whose status is derived from quantities, and a buyer-facing display that “MUST NOT infer a single global linear Order status.” My inference is that platforms will show per-component progress for expand-style kits whether or not the business intended it. If your kit really ships as one box, consider emitting events against the parent only, or against all components in one event, so the display matches the parcel.
Checklist
Bundles (#976):
- Sum only lines without
parent_idfor subtotal,items_discountand verification. - Validate: component
totalsum equals parentsubtotal, exactly. - Persist
parent_idand the per-unit component ratio. Do not re-explode the BOM. - Decide kit vs assembly vs merchandising bundle per SKU, and whether to expose components.
- Allocate the parent’s own discount to components with largest remainder, and store per-unit amounts.
- Use the business’s component allocation as the tax basis, or get the tax engine output.
- Post revenue and tax on one level only.
- Define how the parent’s
quantity.fulfilledfollows component shipments. - Define how a one-component return is referenced and refunded.
Fulfillment events (#979):
- Map every WMS/3PL status to one of the five values, a custom type, or “no event”.
- Put nuance in
descriptionon standard values. Never rename a standard semantic. - Require a non-empty, readable
descriptionon every custom type, including historical ones. - Dedupe on
Webhook-Id, then on event id. Keep state per quantity, forward-only. - Never sum event quantities. Never read
fulfilledas delivered. - Route unknown types to an exception bucket, not the nearest happy-path state.
- Use expectations for backorders and Adjustments for returns.
What I could not settle
- Whether either PR merges as written. Both are open with no reviews as of October 23, 2026. #979 needs Technical Council review.
- Parent
quantity.fulfilledandstatusfor a partially shipped bundle. Neither PR defines it. - Whether adjustments and fulfillment events should reference the parent, the components, or either. Not stated.
- Tax on components. #976 is silent, and its examples carry no tax lines.
- How the parent’s own discount should be allocated to components, if at all. The invariant stops at the subtotal.
- Event
iduniqueness scope (per order or global). The schema requires an id but I did not find a scope. - Tracking requirements on custom and
canceledevents. Onmain,tracking_numberandtracking_urldescriptions say “required if type != processing”, but they are not in the schema’srequiredlist, and #979 does not change that text. Acanceledevent before handoff has no tracking number. - Whether “out for delivery” and store pickup belong to
in_transitanddelivered. That is my reading of the definitions, not the PR’s text. - Add-ons. Explicitly out of scope for #976. Warranties and similar additive children have no defined semantics yet.