Skip to content

Expressions and policy rules

The run context, JSON Logic expressions, {{path}} text templates, the variables nodes set, the policy check's rules, ship-to addresses and the pricing chain.

Node configs hold data, not code. Conditions are JSON Logic expressions and text fields interpolate {{path}} templates, so a flow is safe to store, to diff and to accept from the copilot. Both are evaluated by packages/core/src/logic.ts.

Every expression and template sees one object:

Path Contents
order The order: id, type, title, customerId, currency, lines, meta, subtotal, tax, total, createdAt, and once priced fxRate and baseTotal (see Currency). Pricing and tax nodes update the totals as they run
vars Run variables: seeded by the start request’s vars, then set by nodes and events (below). Carried across continue-as-new
nodes Each node’s latest output, by node id, for example nodes.risk.score
input The order’s meta
now When this Workflow run started, in epoch milliseconds

Fields of kind logic take a JSON Logic expression: Condition’s expression, Loop’s while, Wait’s until, Refund’s amount, and each value in Set variables’ assign. { "var": "order.total" } reads a path from the context.

{ "and": [
{ ">": [{ "var": "order.total" }, 1000] },
{ "==": [{ "var": "vars.category" }, "it_hardware"] }
] }

OrderLoop adds three operations:

Operation Result
{ "now": [] } The current time in epoch ms
{ "days": [n] } n days in ms: { "+": [{ "var": "now" }, { "days": [14] }] }
{ "len": [x] } Length of an array or string, else 0

A condition is true by JSON Logic’s truthiness: empty arrays, 0, "" and null are false. A plain value (a number, true) is its own result, which is why Loop’s default while is true.

Expressions that read the clock run inside a step, so a replay sees the same value.

Text fields replace {{path.to.value}} with the value at that path in the run context. A missing value becomes an empty string and an object is written as JSON. Templates apply to Approval’s title, Notify’s subject and body, End’s reason, Refund’s reason and Generate PO’s supplier.

Order {{order.id}} is on its way ({{vars.tracking}})
Variable Set by
policyFailures Policy check: the failure messages
suggestedAddress Validate address, on corrected: the normalised ship-to
refunded Refund, on ok: the amount refunded
invoiceKey, dueDate Generate invoice
poKey, supplier Generate PO (Demand forecast may also set supplier)
tracking Create shipment
returnTracking Return label
erpRef ERP sync
poNumber Extract order
risk Risk score: the 0–100 score
configurable (category) Classify: the label it chose, under its into field
configurable (summary) Summarize: the summary, under its into field
any Set variables: one per key in assign

A Wait for event node stores the event’s payload under the event name, with each run of characters other than letters and digits replaced by _: return.requested becomes vars.return_requested, so a refund can use { "var": "vars.return_requested.amount" }.

The Policy check node’s rules field is a list of rule objects, checked by packages/core/src/policy.ts. Every rule runs, and the node takes fail with every failure listed (in vars.policyFailures), so a customer hears about all of them at once. The rule names are min_order_amount, max_order_amount, max_qty_per_sku, business_hours, no_po_box, restricted_skus.

Rule Fields Fails when
min_order_amount amount order.total is below amount
max_order_amount amount order.total is above amount
max_qty_per_sku qty (integer) Any SKU’s quantity, summed across lines, exceeds qty
business_hours start (0–23), end (1–24), days (0 = Sunday … 6; default Monday–Friday), timeZone (default UTC) The order is checked outside [start, end) on the listed days, in timeZone
no_po_box order.meta.shipTo (a string, or an object of address lines) contains a PO box
restricted_skus skus (non-empty) Any line’s SKU is in skus
[
{ "rule": "min_order_amount", "amount": 25 },
{ "rule": "max_qty_per_sku", "qty": 500 },
{ "rule": "business_hours", "start": 8, "end": 18, "timeZone": "America/Chicago" },
{ "rule": "no_po_box" }
]

Validate address reads order.meta.shipTo as an object with line1, city, postalCode and country, and optionally name, line2 and region. checkAddress in packages/core/src/address.ts trims and collapses whitespace, maps country names to two-letter codes ("usa" → US, "UK" → GB), and requires a state or province for US, CA and AU, accepting the full name ("Illinois" → IL). It formats postcodes for US (627011234 → 62701-1234), CA (M5V 3L9), GB (SW1A 2AA), AU and DE; other countries only need one. The node takes valid when nothing changed, corrected when normalising changed a field (the result is in vars.suggestedAddress, the order is untouched), and invalid with every problem in its summary and output, including a missing or free-text ship-to.

{ "line1": "1 Main St", "city": "Springfield", "region": "IL", "postalCode": "62701", "country": "US" }

Price & quote prices each line through a chain of sources in price_sources, lowest priority first. The seeded chain is price books (10, stops), contract prices (20, stops) and volume tiers (30, keeps going), with list price (products.price) as the fallback. resolvePrice in packages/core/src/pricing.ts:

  1. For each active source in order, finds its entries that match the line and takes the lowest price among them, so the deepest volume tier wins.
  2. Adds that price as a candidate. If the source has stop_processing, the chain ends there.
  3. Gives the line the lowest candidate, or the list price when no source matched.

A matching contract price therefore wins even above list price, and with the seeded priorities a customer’s contract is reached before a cheaper volume tier. Entries in price_rules (price books and volume tiers) match when every scope they set fits: sku, customer_id, customer_tier, min_qty (the line’s quantity), and the starts_at/ends_at window. Each sets either a fixed price or discount_pct off the list price. Contract prices are the rows in contract_prices for the order’s customer.

The node’s useContracts and usePriceRules fields drop the contract source or the price-book and volume sources for that node. A line with its own unitPrice keeps it, and purchase requests and reorders price at products.cost without the chain. The node’s output lists each line’s unitPrice, the source (by) and the entry (rule), and its summary counts lines per source. The back office shows the chain in order.

An order’s currency is any code with a row in fx_rates; the REST API answers 400 for one without. Prices, price rules, credit limits, balances, budgets and reports are in the base currency, USD (BASE_CURRENCY in core). Price & quote reads the rate (units of the order’s currency per one USD), converts each base price at it, and stores it on the order as fxRate and on the orders row as fx_rate, so later rate changes never reprice the order. A unitPrice given on the order is already in the order’s currency. Totals carry baseTotal, the total in USD.

  • Amounts on the order, its documents, payments and refunds are in the order’s currency.
  • Credit check and budget check compare baseTotal; the credit check reports in the order’s currency, and invoicing adds baseTotal to the customer’s balance.
  • The dashboard converts at each order’s fx_rate, so its figures are in USD.
  • order.total in an expression is in the order’s currency; use order.baseTotal for a threshold that should hold across currencies. Policy rule amounts compare order.total.

The seeded rates (EUR 0.92, GBP 0.79, CAD 1.37) are demo values; a real deployment refreshes fx_rates from a rates provider.

Fields of kind duration are a whole number and a unit: second, minute, hour, day, week, month (30 days) or year (365 days), singular or plural: "30 seconds", "2 days". In the engine every wait and timeout is divided by TIME_SCALE and never drops below one second.