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:

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 with parent_id is a component of the line with that id, and together they “form a bundle — one purchasable unit, priced as a whole, composed of individually identifiable products.” parent_id MUST reference a different line in the same array. A line with parent_id MUST NOT itself be referenced as a parent.
  • Quantity is total, not per unit. “A component’s quantity is 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’s totals[], never additive to them.”
  • One invariant. “The sum of component totals[type=total].amount values MUST equal the bundle parent’s totals[type=subtotal].amount.”
  • Aggregates skip components. Lines with parent_id “MUST be excluded from subtotal, from items_discount, and from any platform verification sum; including them double-counts the bundle.” The Discount extension’s items_discount invariant 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_id establishes 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 future parent_type discriminator 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:

Lineitem.pricesubtotalitems_discounttotal
li_kit (parent)53005300−500 (“Bundle Savings”)4800
li_mug (component)20002000−2001800
li_beans (component)35003500—3500
Checkout totals5300−5004800

Three things matter for an ERP:

  1. Component list prices do not add up to the parent price. 2000 + 3500 = 5500, while the kit is 5300. item.price on a component is not an allocation. The component’s total is.
  2. 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).
  3. 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’s items_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_id relationship 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 total equals the parent subtotal, 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:

ValueNormative meaning (quoted, abridged)
processingThe Business is preparing the goods or package before handoff to a carrier or delivery network.
shippedThe goods have left the Business’s control, normally through handoff. “A carrier-network movement scan is not required.”
in_transitThe carrier or delivery network “has confirmed that the package is moving.”
deliveredDelivered to the buyer or the agreed delivery location.
canceledThis fulfillment was canceled and “is not expected to complete. This does not necessarily mean that the entire Order was canceled.”

The surrounding rules:

  • type stays an open string and “MUST NOT be represented as a closed enum in generated SDKs.”
  • Businesses MUST use the exact lowercase snake_case value when the semantics match. “A custom value MUST NOT rename a well-known semantic”: finished meaning delivered must be delivered, “even if a description is provided.”
  • Custom values (failed_attempt, undeliverable, returned_to_sender are given as examples) MUST carry a “non-empty, consumer-understandable description.” The schema enforces this with an if/then on types outside the five values and adds minLength: 1 to description. The PR notes that whitespace alone passes minLength but does not meet the rule.
  • An explicitly empty description is 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 description as 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[].status is 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 derive quantity.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 statusUCP event typeNotes
Released to warehouse, allocated, picking, packedprocessingGoods still in the business’s control.
Label created, awaiting pickupprocessingThe PR says label creation alone is not shipped.
Manifested, handed to carrier, left the dockshippedNo carrier scan required.
First carrier scan, in networkin_transitRequires carrier confirmation.
Out for deliveryin_transit + descriptionInference: a stage of in-network movement, so use the standard value with detail, not a custom synonym.
Delivered, picked up by buyer at agreed locationdeliveredWhether store pickup counts as “agreed delivery location” is my reading.
Shipment or fulfillment order canceled, short-ship cancelcanceledScoped 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 customNot a physical fulfillment fact yet. Inference: update the expectation (fulfillable_on, description) instead of inventing an event.
Delivery exception, delay, failed attemptcustom (e.g. failed_attempt) + descriptionThe PR’s own custom example.
Returned to sender, undeliverable, lostcustom + descriptionNo 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 delivered event followed by a later processing event on the same line for the other unit, and states that the later one “does not undo the earlier delivery.”
  • Order by occurred_at within a scope, but expect late arrivals. Carriers backfill scans. An in_transit that arrives after delivered for the same quantities is history, not a regression. Keep transitions forward-only, as in the poller from Shop MCP Without Webhooks.
  • Take quantity.fulfilled from 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 from delivered and 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):

  1. Inventory every distinct type you have ever emitted, including historical events that still appear in order snapshots.
  2. Classify each one as a synonym of a well-known value (finished, complete, cancelled, IN_TRANSIT, in-transit, label_created → processing) or genuinely custom.
  3. 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.
  4. Write a consumer-readable description for every custom type, with no internal codes, and nothing that is unsafe to render.
  5. Validate full snapshots against the new schema in CI, including the conditional description rule, before you advertise the new version.
  6. Watch for any check in your own code that treats type as 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_id for subtotal, items_discount and verification.
  • Validate: component total sum equals parent subtotal, exactly.
  • Persist parent_id and 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.fulfilled follows 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 description on standard values. Never rename a standard semantic.
  • Require a non-empty, readable description on 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 fulfilled as 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.fulfilled and status for 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 id uniqueness scope (per order or global). The schema requires an id but I did not find a scope.
  • Tracking requirements on custom and canceled events. On main, tracking_number and tracking_url descriptions say “required if type != processing”, but they are not in the schema’s required list, and #979 does not change that text. A canceled event before handoff has no tracking number.
  • Whether “out for delivery” and store pickup belong to in_transit and delivered. 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.