Switchboard DocsPolaris-native Shopify Functions

Cart Validation

Blocks checkout when cart attributes match merchant-defined rules. Powered by Shopify's cart.validations.generate.run Function target.

The validation function reads ONE cart attribute (_switchboard, a JSON-encoded blob of key→value pairs) and evaluates merchant-authored rule trees against it. On match, Shopify shows your custom error message and prevents the customer from progressing past the configured block-type step.


Mental model

  1. Storefront writes attributes into the _switchboard cart attribute (a single rolled-up JSON blob). Attribute keys are arbitrary — whatever you want to evaluate against.
  2. Admin defines rules as a tree of groups, where each group is a list of comparisons (attribute is operator value). Groups combine with AND/OR.
  3. Function evaluates at the configured block-type step. If the tree matches, the function emits a validation error with the merchant's message.

The cart attribute and the rule both reference attribute keys by name. As long as the storefront writes the same key the rule reads, things match up.


Block types — when the error surfaces

Shopify's validation framework supports three block types:

Block type When the error fires What the customer sees
Cart Whenever the cart changes, including the moment they click Checkout Error shown on the cart page; cannot proceed
Checkout At the contact/shipping step in checkout Error inside checkout; cannot advance
Checkout completion At the final "Pay now" / order-submission step Error at the payment step; payment cannot complete

Pick the surface where you want to stop the customer:

  • Stop them at the cartCart (most aggressive — they can't even leave the cart)
  • Let them build the cart, block at checkoutCheckout (good for "complete your shipping info" gates)
  • Let them see prices/tax/shipping but block at paymentCheckout completion (good for "we need to manually review" holds)

Rule tree shape

RuleTree = {
  operator: "AND" | "OR",   // how groups combine
  groups: [
    {
      operator: "AND" | "OR",   // how rules within this group combine
      rules: [
        { attribute, comparator, operator, value? }
      ]
    },
    ...
  ]
}

Top-level operator: how the groups combine. "AND" = all groups must match; "OR" = any group matches → fire.

Group operator: how rules within a group combine. Same semantics.

Rule: one comparison between a cart attribute and a value.

This shape lets you express logical formulas like:

(country = "US" AND order_value > 5000) OR (fraud_score > 80)

which is:

{
  "operator": "OR",
  "groups": [
    {
      "operator": "AND",
      "rules": [
        { "attribute": "country", "comparator": "is", "operator": "equals", "value": "US" },
        { "attribute": "order_value", "comparator": "is", "operator": "greater_than", "value": 5000 }
      ]
    },
    {
      "operator": "AND",
      "rules": [
        { "attribute": "fraud_score", "comparator": "is", "operator": "greater_than", "value": 80 }
      ]
    }
  ]
}

The admin UI builds this for you via Add Group / Add Rule buttons — you don't author the JSON by hand.


Supported operators

Operator What it does Notes
equals Exact string equality Compares stringified values; case-sensitive
contains Substring match Case-sensitive
starts_with Prefix match Case-sensitive
ends_with Suffix match Case-sensitive
greater_than Numeric > Both sides parsed as numbers
less_than Numeric < Both sides parsed as numbers
exists Attribute is set to a non-empty value Ignores value field

Comparatorsis or is not — wrap any of the above to negate (e.g. country is not equals "CA").


Storefront contract

The function reads one well-known cart attribute: _switchboard. Its value is a JSON-encoded object whose keys are what your rules reference.

Easiest path — Switchboard SDK:

// Set one attribute
Switchboard.setCartAttribute('country', 'US');
Switchboard.setCartAttribute('order_value', '15000');

// Set several at once (single cart write)
Switchboard.setBatch({
  country: 'US',
  order_value: '15000',
  loyalty_tier: 'vip'
});

// Clear one
Switchboard.clear('country');

// Read one back
Switchboard.read('country').then(console.log);

Declarative — bind form inputs:

<select data-switchboard-attr="loyalty_tier">
  <option value="basic">Basic</option>
  <option value="vip">VIP</option>
</select>

<input type="checkbox" data-switchboard-attr="age_confirmed">

The SDK syncs these to the cart attribute on every change. No JS required.

Don't want the SDK? Write the rollup attribute directly:

fetch('/cart/update.js', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    attributes: {
      _switchboard: JSON.stringify({ country: 'US', order_value: '15000' })
    }
  })
});

See /docs/sdk for the full SDK reference.


Worked examples

Country gating

Problem: "We only ship to Canada. Block US orders at the cart with a clear message."

Rule:

  • Group 1 (AND):
    • attribute country, comparator is not, operator equals, value CA

Message: "We currently only ship within Canada. Please reach out for special arrangements."

Block type: Cart.

Storefront (Liquid in theme.liquid):

{%- if customer and customer.default_address.country_code -%}
  <script>
    document.addEventListener('DOMContentLoaded', function () {
      if (window.Switchboard) {
        Switchboard.setCartAttribute('country', {{ customer.default_address.country_code | json }});
      }
    });
  </script>
{%- endif -%}

Tiered minimums

Problem: "Wholesale customers must have a $500 minimum order."

Rule:

  • Group 1 (AND):
    • loyalty_tier, is, equals, wholesale
    • cart_value_cents, is, less_than, 50000

Message: "Wholesale orders have a $500 minimum. You're at $X.XX — add a bit more or contact us."

Block type: Cart (preferred — they shouldn't even reach checkout if they're under the floor).

Storefront: Liquid sets loyalty_tier from customer.tags; JS watches the cart and updates cart_value_cents on every cart change.

Age gating

Problem: "We sell knives — confirm 18+ before checkout."

Rule:

  • Group 1 (AND):
    • age_confirmed, is not, equals, true

Message: "Please confirm you are 18+ to continue."

Block type: Cart.

Storefront (no JS — use declarative mode):

<label>
  <input type="checkbox" data-switchboard-attr="age_confirmed" value="true">
  I confirm I am 18 years or older.
</label>

The SDK auto-syncs this to the cart attribute on change.

Conflict blocking

Problem: "Customers shouldn't be able to mix subscription + one-time of the same product."

Rule:

  • Group 1 (AND):
    • subscription_conflict, is, equals, true

Message: "You have a subscription and a one-time purchase of the same product in your cart. Please choose one."

Block type: Cart.

Storefront (JS detects conflict on add-to-cart):

function checkConflict(cart) {
  const counts = {};
  for (const item of cart.items) {
    const key = item.product_id;
    counts[key] = (counts[key] || 0) + (item.selling_plan_allocation ? 1 : 2);
  }
  // If any product has BOTH a subscription line and a one-time line, the count is 3
  const conflict = Object.values(counts).some(c => c === 3);
  Switchboard.setCartAttribute('subscription_conflict', conflict ? 'true' : 'false');
}

Manual review hold

Problem: "Carts over $5,000 need manual review — let the customer see prices but block at payment."

Rule:

  • Group 1 (AND):
    • cart_value_cents, is, greater_than, 500000

Message: "Orders over $5,000 need a quick manual review. Please email us at orders@example.com with your cart contents and we'll process this for you."

Block type: Checkout completion — they can build the cart, walk through shipping, see prices and tax — but can't actually submit payment.


Fail-safe behavior

The function NEVER throws and NEVER blocks checkout on bad input. On any failure (malformed _switchboard JSON, missing attributes, malformed rule trees), no validation is emitted and the customer proceeds normally.

Failure Result
_switchboard cart attribute is malformed JSON Treated as empty attributes; rules can't match
Attribute referenced by rule is missing from the blob That rule evaluates to false
Rule operator is unknown (shouldn't happen — admin enforces) Rule evaluates to false
Validation metafield missing Function emits no errors; checkout proceeds
Inactive rule Skipped at sync time — never reaches the function

The takeaway: misconfiguration never breaks checkout. The worst case is that a rule SILENTLY DOESN'T FIRE when you expected it to. Test with the storefront DevTools console or the QA plan in SHOPIFY_QA.md.


Architectural notes

  • One Shopify Validation record per shop. All merchant rules get rolled up into a rule_trees metafield on that single Validation. The Shopify Validation's enforcementLevel is per-record; Switchboard sets it from each rule's block-type at sync time. (Technically Shopify allows one block-type per Validation; multi-block-type rules are partitioned client-side and synced as a single metafield with mixed entries.)
  • Rule trees are evaluated by a pure function (app/lib/validation/evaluator.ts mirrored in extensions/switchboard-validation/src/evaluator.js). Same matrix of unit tests on both sides. If you change one, you change the other.
  • Cart attribute keys are arbitrary — the merchant decides what to call them. Switchboard recommends names that are easy to debug in the order detail view (country, cart_value_cents, loyalty_tier).

Common pitfalls

  • Empty validation message — Shopify allows empty messages, but customers see no explanation. Always write a message.
  • Forgetting to set the attribute on every page — if your Liquid snippet is in a section that only loads on one page, the attribute may not be set when the customer is on another page. Put it in theme.liquid or a snippet included from theme.liquid.
  • Stringifying numbers — cart attribute values are always strings in Shopify. Switchboard's greater_than / less_than operators auto-parse to numbers, but if your value contains "$15" instead of "15", the parse fails. Store raw numeric strings.
  • Block type confusion — Cart block fires aggressively (any cart change). If you don't want the customer to see the error while they're shopping, use Checkout or Checkout completion.

See SHOPIFY_QA.md in the repo for the full test plan you can hand to reviewers.