> ## Documentation Index
> Fetch the complete documentation index at: https://docs.launchmystore.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Fulfillment Constraints

> Restrict which fulfillment locations can ship which cart lines and block orders that cannot be fulfilled

# Fulfillment Constraints

`fulfillment_constraints` functions run at order placement and decide,
**per cart line**, which fulfillment locations are allowed to ship it.
If a line ends up with **zero** allowed locations the order is blocked
with a clear error. Non-blocking outputs (every line has at least one
allowed location) are persisted onto the order so the downstream routing
engine can pick the location without re-running the function.

Use it when:

* Some SKUs are only stocked at certain warehouses (single-source items).
* A line is **hazmat** and must ship from a licensed hub only.
* An **express-shipping** promise can only be honoured from specific
  regions.
* A line carries a **made-to-order** attribute and must route to the
  manufacturer.
* A line is **digital** and should bypass physical locations entirely.

`fulfillment_constraints` is the WASM-backed, code-driven counterpart to
[order routing rules](/extensions/order-routing). Both narrow which
location ships which line; this one can also **block** the order.

## How it works

```mermaid theme={null}
sequenceDiagram
    participant Checkout
    participant OrderSvc as Order Service
    participant Validate as order_validation fns
    participant Constraint as fulfillment_constraints fns
    participant Router as Routing rules / engine

    Checkout->>OrderSvc: Place order
    OrderSvc->>Validate: dispatch
    Validate-->>OrderSvc: errors (or none)
    alt validation passed
      OrderSvc->>Constraint: dispatch (per app)
      Constraint-->>OrderSvc: constraints[]
      alt any line has empty allowedLocationIds
        OrderSvc-->>Checkout: HTTP 400 (FulfillmentConstraintsFailed)
      else all lines have at least one allowed location
        OrderSvc->>OrderSvc: stash on order.additionalFields.fulfillmentConstraints
        OrderSvc->>Router: pass narrowed set
        OrderSvc-->>Checkout: order placed
      end
    end
```

The function runs at order placement **immediately after**
`order_validation` and **before** the order row is persisted. If you
return an empty `allowedLocationIds` for any line, the entire order is
rejected — partial blocking is not supported.

Per-shop cap: **5** active `fulfillment_constraints` apps (matches
`order_validation`). The dispatcher runs every installed app of this
type and collects their constraints into a single union before
evaluating blocks.

## Manifest

```json theme={null}
{
  "handle": "warehouse-routing",
  "name": "Warehouse Routing",
  "version": "1.0.0",
  "extensions": {
    "functions": [
      {
        "type": "fulfillment_constraints",
        "handle": "constrain-by-attribute",
        "title": "Warehouse routing by attribute",
        "entrypoint": "dist/fulfillment-constraints.wasm",
        "inputFields": {
          "cart": {
            "lines": {
              "id": true,
              "quantity": true,
              "merchandise": {
                "id": true,
                "sku": true,
                "attributes": true
              }
            }
          },
          "fulfillmentLocations": true,
          "shippingAddress": { "country": true, "province": true }
        }
      }
    ]
  }
}
```

`type` must be `fulfillment_constraints`. `entrypoint` is the path
inside your app bundle to the compiled WASM. The `inputFields`
projection (see [Input fields](/functions/input-fields)) trims the
payload to just the fields you read — recommended for fast dispatch.

## Input shape

```typescript theme={null}
interface FulfillmentConstraintsFunctionInput {
  cart: {
    items: CartLineItem[];          // Note: surfaced as `items`, not `lines`
    totalPrice: number;
    itemCount: number;
    currency: string;
    localization?: {
      country: string;
      language: string;
      presentmentCurrencyRate?: number;
    };
    metafields?: MetafieldBag;       // cart-scoped metafields
  };
  shippingAddress?: {
    firstName?: string;
    lastName?: string;
    address1?: string;
    city?: string;
    province?: string;
    country?: string;
    zip?: string;
  };
  // Catalog of fulfillment locations available to the shop.
  // NOTE: in the current build, the dispatcher passes an EMPTY array here
  // until the platform's location catalogue ships. Apps that
  // need a location list should keep their own map in app metafields or
  // hardcode the gid()s they manage.
  fulfillmentLocations: Array<{
    id: string;                     // gid://launchmystore/Location/<uuid>
    gid?: string;
    name?: string;
    address?: AddressInput;
    capabilities?: string[];        // e.g. ["hazmat", "cold_chain"]
  }>;
}

interface CartLineItem {
  id: string;                       // cart line id; pass back in constraints[].lineId
  gid?: string;                     // gid://launchmystore/CartLine/<uuid>
  variantId: string;
  productId: string;
  title: string;
  quantity: number;
  price: number;
  sku?: string;
  properties?: Record<string, string>;
  metafields?: MetafieldBag;
  merchandise?: {
    id: string;
    productId: string;
    metafields?: MetafieldBag;
    product?: { id: string; metafields?: MetafieldBag };
  };
}
```

<Note>
  **`cart.items` vs `cart.lines`.** The platform serializes cart contents
  as `items` for back-compat with existing apps; some examples in this doc
  use `lines` (the canonical name in our input-field schema). Treat them
  as the same array. If your function uses `inputFields` to project, list
  `cart.lines` — the projector aliases both.
</Note>

## Output shape

```typescript theme={null}
interface FulfillmentConstraintsFunctionOutput {
  constraints: Array<{
    lineId: string;                 // must match cart.items[].id
    allowedLocationIds: string[];   // empty = block this line
    message?: string;               // surfaced to the customer when blocking
  }>;
}
```

Rules:

* `lineId` **must** match a `cart.items[].id` returned by the platform.
  An unknown lineId is silently ignored.
* `allowedLocationIds` is the **narrowed** set — locations your function
  approves. An empty array blocks the order.
* `message` is only displayed when blocking. If omitted, the customer
  sees `"Line <lineId> cannot be fulfilled from any location"`.

You only need to return constraints for lines you have an opinion on.
Lines without a constraint entry are unconstrained — the routing engine
can ship them from any location.

## Validator

The platform validates output shape strictly:

| Check                                                   | Behaviour on failure               |
| ------------------------------------------------------- | ---------------------------------- |
| Output is non-null object with `constraints` array.     | Function result discarded; logged. |
| Every `constraints[].lineId` is a string.               | Function result discarded; logged. |
| Every `constraints[].allowedLocationIds` is `string[]`. | Function result discarded; logged. |

A discarded result behaves as if the function returned no constraints —
the order proceeds. This is intentional: a buggy app must not be able to
take a merchant's storefront offline.

## Worked examples

### Example: hazmat → licensed hub only

Preventing flammable / hazardous SKUs from shipping from a regular DC.

```javascript theme={null}
// fulfillment-constraints.js — compiled to WASM
export default function main(input) {
  // Build the set of locations licensed for hazmat shipment. In this
  // build the dispatcher passes an empty `fulfillmentLocations`, so
  // fall back to a hard-coded id if no catalogue arrived.
  const fromCatalogue = (input.fulfillmentLocations || []).filter(
    (l) => Array.isArray(l.capabilities) && l.capabilities.includes('hazmat'),
  );

  const hazmatLocations = fromCatalogue.length > 0
    ? fromCatalogue.map((l) => l.id)
    : ['gid://launchmystore/Location/hazmat-hub'];

  const constraints = (input.cart.items || input.cart.lines || [])
    .filter((line) => line.merchandise?.attributes?.hazmat === 'true')
    .map((line) => ({
      lineId: line.id,
      allowedLocationIds: hazmatLocations,
      message:
        'This item contains hazardous materials and ships from our licensed warehouse only.',
    }));

  return { constraints };
}
```

If no `hazmat-hub` is configured for the shop and the catalogue is
empty, the function returns the hard-coded id and the routing engine
will use it. If the merchant later wires the catalogue, the function
auto-picks up the right ids without redeploy.

### Example: oversold lines → block

Prevent a checkout when an in-cart line has zero stock anywhere. This
is the canonical "fail-loud" use case.

```javascript theme={null}
export default function main(input) {
  const constraints = [];

  for (const line of input.cart.items || input.cart.lines || []) {
    const eligible = (input.fulfillmentLocations || []).filter((loc) => {
      // Read per-variant per-location inventory from a metafield that the
      // app populates via its install hook. Schema:
      //   inventory: { byLocation: { [locationId]: { quantity } } }
      const inv = line.merchandise?.metafields?.app_inventory?.by_location?.value;
      const atLoc = inv?.[loc.id];
      return atLoc && atLoc.quantity >= line.quantity;
    });

    if (eligible.length === 0) {
      constraints.push({
        lineId: line.id,
        allowedLocationIds: [],   // empty → block
        message: `${line.title} is out of stock and cannot be shipped right now.`,
      });
    } else {
      constraints.push({
        lineId: line.id,
        allowedLocationIds: eligible.map((l) => l.id),
      });
    }
  }

  return { constraints };
}
```

When even one line resolves to `allowedLocationIds: []`, the entire
order is blocked with HTTP 400 and all the function's messages are
joined into a single human-readable string.

### Example: geo-restricted SKUs

Some SKUs (knives, alcohol, electronics with regional certifications)
are only legal to ship within certain regions.

```javascript theme={null}
export default function main(input) {
  // Country-specific routing for restricted items. Keyed by a sku-prefix
  // convention enforced by the merchant's PIM.
  const restrictedPrefixes = {
    'KNIFE-': { allowed: ['US'], locations: ['gid://launchmystore/Location/us-knife-hub'] },
    'ALCO-':  { allowed: ['US', 'CA'], locations: ['gid://launchmystore/Location/us-alco-hub'] },
  };

  const destCountry = input.shippingAddress?.country;

  const constraints = [];
  for (const line of input.cart.items || input.cart.lines || []) {
    const sku = line.merchandise?.sku || line.sku || '';
    const match = Object.entries(restrictedPrefixes).find(([prefix]) =>
      sku.startsWith(prefix),
    );

    if (!match) continue;  // not restricted

    const [, rule] = match;
    if (!rule.allowed.includes(destCountry)) {
      constraints.push({
        lineId: line.id,
        allowedLocationIds: [],
        message: `${line.title} cannot be shipped to ${destCountry}.`,
      });
    } else {
      constraints.push({
        lineId: line.id,
        allowedLocationIds: rule.locations,
      });
    }
  }

  return { constraints };
}
```

### Example: digital lines bypass physical locations

Digital downloads should not consume a physical-location slot.

```javascript theme={null}
const DIGITAL_LOCATION = 'gid://launchmystore/Location/digital-fulfillment';

export default function main(input) {
  const constraints = (input.cart.items || input.cart.lines || [])
    .filter((line) => line.merchandise?.attributes?.fulfillment_type === 'digital')
    .map((line) => ({
      lineId: line.id,
      allowedLocationIds: [DIGITAL_LOCATION],
    }));

  return { constraints };
}
```

### Example: combine narrowing + non-blocking pass-through

A common pattern: narrow some lines (regional), block others (oversold),
let the rest fall through unconstrained.

```javascript theme={null}
export default function main(input) {
  const constraints = [];
  const lines = input.cart.items || input.cart.lines || [];

  for (const line of lines) {
    const attrs = line.merchandise?.attributes || {};

    if (attrs.hazmat === 'true') {
      constraints.push({
        lineId: line.id,
        allowedLocationIds: ['gid://launchmystore/Location/hazmat-hub'],
      });
    } else if (attrs.fulfillment_type === 'digital') {
      constraints.push({
        lineId: line.id,
        allowedLocationIds: ['gid://launchmystore/Location/digital-fulfillment'],
      });
    } else if (line.merchandise?.metafields?.inventory?.total?.value === 0) {
      constraints.push({
        lineId: line.id,
        allowedLocationIds: [],
        message: `${line.title} is out of stock.`,
      });
    }
    // Other lines: no constraint entry → routing engine picks any location.
  }

  return { constraints };
}
```

## Error behaviour when no eligible location exists

When **any** constraint resolves to `allowedLocationIds: []`, the order
is rejected with:

```http theme={null}
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "statusCode": 400,
  "message": "error",
  "data": null,
  "error": "This item ships from our hazmat-licensed warehouse only.; OUT_OF_STOCK_LINE_2 is out of stock.",
  "errors": [
    {
      "cartLineId": "cl_abc...",
      "reason": "This item ships from our hazmat-licensed warehouse only.",
      "appId": "warehouse-routing"
    },
    {
      "cartLineId": "cl_def...",
      "reason": "OUT_OF_STOCK_LINE_2 is out of stock.",
      "appId": "warehouse-routing"
    }
  ],
  "code": "FulfillmentConstraintsFailed"
}
```

The customer-facing message is the concatenation of every blocking
constraint's `message`, joined with `"; "`. The structured `errors[]`
array carries enough metadata for the checkout to surface each
reason inline next to the offending line.

Code is always `FulfillmentConstraintsFailed` — clients can switch on
this to render a fulfilment-specific error UI instead of a generic
validation error.

## Combination with order routing rules

When both `fulfillment_constraints` and
[order routing rules](/extensions/order-routing) are active, **constraints
run first**:

1. `fulfillment_constraints` evaluates → blocks (if any) or narrows.
2. Routing rules then evaluate over the narrowed set.
3. The chosen location must be in the constraint's
   `allowedLocationIds` for that line.

A routing rule that picks a location **not** in the constraint set is
ignored for that line, and the engine falls back to the highest-priority
rule whose target is in the allowed set. If no rule's target is in the
allowed set, the engine picks the first id from `allowedLocationIds` in
insertion order.

This composition lets you split concerns cleanly:

* Use `fulfillment_constraints` for **hard physical limits** (no
  inventory; unlicensed location).
* Use [routing rules](/extensions/order-routing) for **preferences**
  (West Coast → Oakland; high-value → expedited).

## Persistence

Non-blocking entries are merged onto the order at
`additionalFields.fulfillmentConstraints[]`:

```json theme={null}
{
  "additionalFields": {
    "fulfillmentConstraints": [
      {
        "lineId": "cl_abc...",
        "allowedLocationIds": [
          "gid://launchmystore/Location/oakland-dc",
          "gid://launchmystore/Location/newark-dc"
        ],
        "appId": "warehouse-routing"
      },
      {
        "lineId": "cl_def...",
        "allowedLocationIds": ["gid://launchmystore/Location/hazmat-hub"],
        "message": "Hazmat — licensed hub only.",
        "appId": "warehouse-routing"
      }
    ]
  }
}
```

The fulfilment dashboard reads this to surface *"this line was
constrained to `<locations>` by app `<appId>`"* when the operations team
opens the order. The downstream routing engine reads it to make the
final location pick.

## Performance considerations

* **Project your input.** `fulfillment_constraints` runs on every order
  placement. A function that reads only `cart.lines[].merchandise.attributes`
  * `shippingAddress.country` should project to just those fields with
    [`inputFields`](/functions/input-fields) — saves \~80% of dispatch time
    on large carts.
* **Avoid network when possible.** [Network access](/functions/network-access)
  is gated behind `network_access: true`. Each outbound call adds up to
  1500 ms to checkout latency. If you need live inventory, cache it on
  the merchant side and refresh out of band.
* **Return early.** If your function only affects a subset of lines, do
  one pass over `cart.items`, push constraints for lines you have an
  opinion on, and ignore the rest. Empty `constraints: []` is a valid
  output.

## See also

* [Order routing rules](/extensions/order-routing) — declarative,
  non-blocking counterpart for region/SKU preference.
* [Order validation functions](/functions/order-validation) — block
  orders **before** constraints run, based on cart-level rules.
* [Cart transform functions](/functions/cart-transform) — populate
  `cart.lines[].merchandise.attributes` so this function has something
  to read.
* [Input field selection](/functions/input-fields) — trim the input
  payload to just what you read.
* [Network access](/functions/network-access) — opt into outbound HTTP
  for live inventory lookups.
