English source · 7 min
Shipping, partial packages and direct carriers
An order has one authoritative price, payment and inventory snapshot. Its physical items can now be allocated to several packages without charging shipping twice or decrementing stock again. Digital items never join a physical package.
flowchart LR
Studio[Studio: select package quantities] --> Command[Native delivery split operation]
MCP[MCP: merchant.delivery.split] --> Command
Command --> Guard[Current orders.write and customers.pii / tenant / revision]
Guard --> PG[(One PostgreSQL transaction)]
PG --> Order[Order and completed cart projection]
PG --> Audit[Actor / revision / idempotency receipt]
PG --> Outbox[delivery.split event]
Outbox --> Flow[Existing Rule and Flow Builder]
Outbox --> Apps[Explicitly subscribed apps]
Order --> Account[Owning customer: packages and tracking]
Use it
Open Orders → an order → Delivery. Each package lists its allocated products and quantities. Split into another package opens a quantity editor. Move a positive subset, leaving at least one item in the original package. The server owns eligibility: only open packages on nonterminal orders can be split. An existing carrier label blocks allocation changes. Automatic cancellation/replacement is not implemented; reconcile it with the carrier before further fulfillment. At most thirty packages belong to one order.
The original package retains its shipping-charge snapshot. Additional packages have zero additional customer charge. The order quote, currency, taxes, payment and inventory allocations stay unchanged. This is operational fulfillment, not a repricing operation. Each package follows the existing open → shipped → delivered state machine; completing an order still requires all its packages to be delivered.
POST /api/merchant/orders/ORDER_ID/deliveries/split and MCP
merchant.delivery.split share these inputs:
{
"revision": 3,
"deliveryIndex": 0,
"positions": [{ "id": "product-id", "quantity": 1 }],
"requestKey": "a-new-stable-request-key"
}
MCP additionally requires id. A lost response is retried with the same body
and request key. Parallel copies create one package, activity and event. Reusing
a key with changed input fails. A new operation needs the current order revision.
Guest/customer credentials and unrelated tenants cannot invoke this operation;
operational packages cannot be staged or selectively released.
The delivery.split Flow event carries the token-redacted order snapshot at that
revision. Existing flow conditions and current action rights still apply. Apps
need explicit event subscriptions and data consent; the event does not give an app
unrestricted order data. Labels and tracking are separate from physical handover:
creating a label must not silently mark goods as shipped. Pending carrier intents
also disable dispatch in that same server-derived action catalogue.
Provider-neutral shipping app contract
No shipping provider is selected or bundled by this release. App Studio's
shipping example declares the
shared contract; an external app implements its chosen carrier. Carrier protocols,
credentials, provider limits, signed booking receipts, recovery and UI belong to
extensions/apps/<carrier>/ or that app's independent repository. They do not belong
to Commerce system directories. The local fixture is test tooling, not a production
shipping service.
sequenceDiagram
participant Merchant as Studio / Flow / MCP
participant Core as Native app jobs and orders
participant App as Shipping app service
participant Carrier as Selected external provider
Merchant->>Core: create_label + stable Idempotency-Key
Core-->>App: Scoped self event containing jobId only
App->>Core: job_claim, current creator rights + package digest
Core-->>App: Input + expiring lease
App->>Core: delivery_snapshot, delivery_reserve
Core-->>App: Immutable allocation digest + fresh/reused intent
Note over App,Carrier: Only a fresh intent may initiate a booking
App->>Carrier: Provider-specific validation and booking
Carrier-->>App: Original shipment identity and document
App->>Core: document_upload: original private PDF + tracking
App->>Core: job_progress: succeeded
Core-->>Merchant: Native package / document / job result
The declared create_label action uses handler: job, orders.write plus
customers.pii and documents.create. It accepts orderId, deliveryIndex, a
server-configured account alias and positive integer weight/dimensions. Provider
maximums and address conversion are the app's responsibility. flowAllowed enables
the same action in the existing Flow Builder; MCP exposure remains an explicit
manifest opt-in. Neither flag grants rights or installs a service.
The service is pinned using the existing APP_SERVICES configuration and approved
package digest. Its callback credential requires explicit orders.read,
customers.pii, deliveries.write, documents.create and jobs.write consent.
Use server-held secrets, never credential fields in public records or frontend code.
Callback sequence
All callbacks are under /api/apps/APP/core/ with the current app credential and
matching x-tenant. Use the existing app SDK or the documented HTTP contract:
| Callback | Input / purpose |
|---|---|
job_claim |
{id: jobId}; claims one native job and returns input, lease and deadlines after current creator rights checks |
job_progress |
{id, lease, progress, message, status, result}; current lease/actor checks, translated bounded messages, cancellation and final result |
delivery_snapshot |
{id: orderId, deliveryIndex}; current package, owning order shipping address and allocationDigest |
delivery_reserve |
{id, deliveryIndex, allocationDigest, requestKey}; transactionally freezes one open package before external work |
delivery_release |
Same intent; release only after a confirmed no-booking outcome, never after an unknown external effect |
document_upload |
Multipart metadata JSON and file PDF; original label acknowledgement through the existing private receipt owner |
A reservation returns reserved: true and reused. A reused reservation never
authorizes a fresh booking, even after an app record was removed or a process
restarted. The same native package cannot be split, dispatched or substituted by
another provider while its booking outcome is unknown. Once the original PDF is
attached, the label fence prevents allocation changes. Creating a label does not
mark dispatch, decrement stock, reprice an order or alter payment state.
The app persists the provider's original identity before document attachment. Recovery must retrieve that same document, with server-authenticated binding to the original tenant/order/allocation/account; merchant-editable provider IDs alone must never authorize another document lookup. A generic native reservation cannot prove that a carrier accepted a request or that a release is truthful. Those checks belong to the provider implementation and its acceptance tests.
Documents, privacy and unknown results
Carrier PDFs are bounded to 2 MiB and counted against the existing 64 MiB aggregate
app storage quota, including retained documents after reinstall. Multipart metadata
contains orderId, deliveryIndex, allocationDigest, a stable requestKey,
kind: shipping_label | return_label and optional trackingCode/HTTPS trackingUrl.
Outbound label import must match that app's original reservation. Identical lost-
response retries return the same immutable document; changed payloads fail.
Customer accounts cannot download outbound labels. Return documents are visible only to the owning registered customer. Importing a return PDF does not implement return-product booking, inspection, restocking or refunds.
Delivery notes can select a package and print only its assigned quantities.
Invoices/cancellation invoices retain their full authoritative fiscal snapshot;
selecting a package for an invoice is rejected. Native and imported documents emit
document.created through the existing outbox. Scoped app events expose IDs rather
than private addresses/cart tokens; a separately admitted callback reads details.
Native job status GET /api/apps/APP/jobs/JOB is available to its original actor or
an app manager who still holds the action rights and package binding. It omits input
and lease; results appear only after success. Manager lists redact private messages
and results if current rights no longer permit them. Expired jobs show an uncertain
outcome; explicit job retry does not remove the package booking fence. Operational
jobs, allocations and receipts cannot be staged or selectively released.
Ownership and checks
commerce/delivery_split.rs: bounded allocation, native revision and tenant checks.commerce/order_mutation.rs: the shared transition/split replay ledger, cart projection, activity and outbox transaction.commerce/order_workflow.rs: server admission consumed by Studio and MCP.admin/orders/ShipmentSplit.tsx: one translated quantity editor, using the parent operation fence and preserving failed commands for correction.extensions/apps/assistant-examples/shipping.json: versioned carrier-neutral job, consent and API declaration consumed by the App Studio assistant.operations/delivery_booking.rs: package-scoped current app admission and reserve/release audit; unknown effects block allocation, dispatch and other carriers.operations/order_documents.rs, migration 092 andapps/core_documents.rs: generic immutable receipt/PDF admission;apps/package_fence.rsshares the existing package write fence with native record writes.scripts/shipping_contract.py: test-only local service driving native jobs, PDFs, tracking, explicit no-effect rejection, unknown-effect fences across a native restart, current job/PII rights and a real split-to-Flow-to-app job.scripts/shipment_workflows.py: real PostgreSQL/HTTP/MCP concurrent replay, invalid/stale/foreign/current-rights checks, customer visibility, unchanged prices and stock, one flow effect and completion of all packages.
Lean checks the extracted open/nonterminal strict positive subset and
unoccupied-or-identical carrier intent decisions.
Compiled comparison cases and negative mutations exercise its production binding.
It does not prove the surrounding SQL, full allocation algorithm, browser or a
carrier's external effects. Original fulfillment/receipt regressions remain in
merchant_operations; no 100% test-coverage or live-carrier acceptance is claimed.
Native order-bound Flow events can supply a declared orderId argument automatically
when it is absent. Flow dispatch rehydrates the current personal actor through the
shared access owner and uses a stable flow-execution job key. The provider-neutral fixture covers
delivery.split → Flow → one app job → original package label; a saved trigger or
manifest flag alone is not treated as execution evidence. The versioned shipping
assistant example and visual assistant use the same durable-job/callback contract.