Skip to content

Rules and Conditions

A policy's rules run top to bottom. For each rule: skip it if enabled is false, skip it if any condition fails, otherwise apply its transform to the running price and move on. Conditions are ANDed - one miss skips the rule.

Rule types

rule_typeParamsEffect on the running price
multiplymultiplier (0.01-100) or multiplier_fieldMultiply by a literal or a context value
addamount (any number, negative allowed)Add a flat amount
subtract_percentpercentage (0-100)Apply a percentage discount
set_fixedfixed_price (>= 0)Replace the price outright
set_from_lookuplookup_table, key_fieldReplace the price from a table, keyed by context
round_psychologicalcharm_ending (0-0.999, default 0.99)Charm-price the current value

Because rules chain, order matters: multiply then add differs from add then multiply, and a round_psychological placed last cleans up whatever the earlier rules produced. set_fixed and set_from_lookup discard the incoming price entirely - verified: a set_from_lookup rule turned a 5.00 base price into the table's 18.00 before a later surge multiplied it.

round_psychological floors to the charm ending (21.6 became 21.99 - it can round up), drops whole numbers first (20.00 becomes 19.99), and leaves prices under 1.00 untouched.

Conditions

Each condition is { "field": ..., "operator": ..., "value": ... }. field is a dot path into the calculation context (user.tier, zone.demand). A field that is absent from the context fails the whole calculation with 400 field_not_found - it does not quietly skip the rule.

Operatorvalue shapeMeaning
equals / not_equalsany JSONExact JSON equality
greater_than / greater_than_or_equalnumberNumeric compare
less_than / less_than_or_equalnumberNumeric compare
between[min, max]Inclusive range, numeric or string
in / not_inarrayMembership
time_in_range[start, end]Clock window, wraps midnight

time_in_range accepts "HH:MM" strings or numeric hours on both the field and the range, and handles wrap-around: with ["22:00", "05:00"], a context clock of 23:30 matches, 04:00 matches, and 12:00 does not.

{
  "name": "vip_discount",
  "rule_type": "subtract_percent",
  "params": { "percentage": 20.0 },
  "conditions": [
    { "field": "user.tier", "operator": "in", "value": ["vip", "gold"] }
  ]
}

Lookup tables

set_from_lookup resolves a key from the context (key_field) against a named table and sets the price to the mapped number. Tables are flat maps of string keys to numbers, created separately (platform admin or editor role):

snug surge-pricing lookup-table create --name zone-base \
  --json '{"downtown": 12.5, "airport": 18.0, "suburbs": 9.0}'
snug surge-pricing lookup-table get --name zone-base
{ "table_name": "zone-base", "entries": 3 }

The key value may be a string, number, or boolean in the context; numbers and booleans are matched against their string form in the table.

References resolve at calculate time, not create time - all reproduced live:

  • A policy naming a nonexistent table creates fine; calculating with it fails 404 lookup_table_not_found.
  • A context key with no table entry fails 400 field_not_found with the message naming table.key (zone-base.harbor).
  • A table entry that is not a number fails 400 invalid_field_value.

There is no list or delete endpoint for lookup tables - create with the same name replaces the table.

Debugging a rule pipeline

--explain on a calculation returns one step per rule that actually ran, in order, with the price after each - skipped rules (disabled or conditions unmet) simply do not appear. The same breakdown comes back from trace get later and from every simulate result. See the README for captured output.