Rule Cascade
LearnLevels and inheritance

Functions

Name an expression once and call it from any rule.

A function gives an expression a name and named parameters. A rule calls it with { fn: <name>, args: [...] }. The body reads its arguments as arg.<param>, and also sees data, original, actor, ctx and params. Functions are part of the ruleset, so they behave the same in every engine.

Syntax

declare and call a function
functions:
  lineTotal:
    description: Quantity times unit price.
    params: [qty, price]
    body: { op: mul, args: [{ var: arg.qty }, { var: arg.price }] }

# in a rule
assert: { op: gt, args: [{ fn: lineTotal, args: [2, 5] }, 0] }

Example

lineTotal computes each order line, and isBlank treats a reference of only spaces as missing. The total 12 does not match the lines (2 × 5), and the reference is blank.

functions.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.functions, version: 1.0.0, title: "Functions" }
scope:
  - { level: organization, id: learn }
entities:
  Order:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
functions:
  lineTotal:
    description: Quantity times unit price.
    params: [qty, price]
    body: { op: mul, args: [{ var: arg.qty }, { var: arg.price }] }
  isBlank:
    description: True for a missing or empty text.
    params: [text]
    body: { op: empty, args: [{ op: trim, args: [{ op: coalesce, args: [{ var: arg.text }, ""] }] }] }
rules:
  - id: order.total.matches-lines
    kind: validation
    target: { entity: Order, field: /total }
    operations: [create]
    assert:
      op: eq
      args:
        - { var: data.total }
        - { op: sum, args: [{ var: data.items }, { fn: lineTotal, args: [{ var: item.qty }, { var: item.price }] }] }
    severity: error
    finding: { code: LRN-FN-001, message: order.totalMismatch }
  - id: order.reference.required
    kind: validation
    target: { entity: Order, field: /reference }
    operations: [create]
    assert: { op: not, args: [{ fn: isBlank, args: [{ var: data.reference }] }] }
    severity: error
    finding: { code: LRN-FN-002, message: order.referenceRequired }
messages:
  en:
    order.totalMismatch: "The total does not match the order lines."
    order.referenceRequired: "Give a reference."
tests:
  - name: a wrong total and a blank reference
    entity: Order
    operation: create
    given:
      data: { reference: "  ", total: 12, items: [{ sku: "pen", qty: 2, price: 5 }] }
    expect:
      decision: deny
      findings:
        - { rule: order.total.matches-lines }
        - { rule: order.reference.required }
  - name: a correct order
    entity: Order
    operation: create
    given:
      data: { reference: "A-1", total: 10, items: [{ sku: "pen", qty: 2, price: 5 }] }
    expect: { decision: allow, findings: [] }
request.json
{
  "entity": "Order",
  "operation": "create",
  "data": {
    "reference": "  ",
    "total": 12,
    "items": [
      {
        "sku": "pen",
        "qty": 2,
        "price": 5
      }
    ]
  }
}

Result, from the engine

Decisiondeny2 findings, server channel

  • LRN-FN-001errorblockingThe total does not match the order lines./total
  • LRN-FN-002errorblockingGive a reference./reference
Try it YourselfOpens this ruleset and request in the playground. Nothing to install.

Common mistakes

  • A function that calls itself, directly or through another function: FUNCTION_RECURSIVE.
  • Calling a function that is not declared (FUNCTION_UNKNOWN) or with the wrong number of arguments (FUNCTION_ARITY).
  • Declaring a function the parent already has: FUNCTION_REDEFINED. Inherited rules depend on what it means.
  • Reading item or an undeclared arg.x inside the body: SCOPE_INVALID. Pass the value as an argument instead, as the example does with item.qty.
functions-recursive.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.functions-recursive, version: 1.0.0, title: "A function that calls itself" }
scope:
  - { level: organization, id: learn }
entities:
  Order:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
functions:
  countdown:
    params: [n]
    body: { op: if, args: [{ op: lte, args: [{ var: arg.n }, 0] }, 0, { fn: countdown, args: [{ op: sub, args: [{ var: arg.n }, 1] }] }] }
rules:
  - id: order.quantity.check
    kind: validation
    target: { entity: Order, field: /quantity }
    operations: [create]
    assert: { op: eq, args: [{ fn: countdown, args: [{ var: data.quantity }] }, 0] }
    severity: error
    finding: { code: LRN-FN-009, message: order.quantityCheck }
messages:
  en:
    order.quantityCheck: "The quantity is not valid."
tests:
  - name: any order
    entity: Order
    operation: create
    given:
      data: { quantity: 3 }
    expect: { decision: allow, findings: [] }
request.json
{
  "entity": "Order",
  "operation": "create",
  "data": {
    "quantity": 3
  }
}

Result, from the engine

does not loadThe engine refuses the ruleset before it evaluates anything.

  • FUNCTION_RECURSIVE function countdown calls itself, directly or indirectly (line 11)
Try it YourselfOpens this ruleset and request in the playground. Nothing to install.

Exercise

Add a function withTax that adds 20% to an amount. Use it in a new rule: the total may not be more than withTax(100). Test it with a total of 130.

Hint

Add withTax with params: [amount] and body mul(arg.amount, 1.2), then a rule asserting that data.total is at most withTax(100) (op lte).

Show answer
functions.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.functions, version: 1.0.0, title: "Functions" }
scope:
  - { level: organization, id: learn }
entities:
  Order:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
functions:
  lineTotal:
    description: Quantity times unit price.
    params: [qty, price]
    body: { op: mul, args: [{ var: arg.qty }, { var: arg.price }] }
  isBlank:
    description: True for a missing or empty text.
    params: [text]
    body: { op: empty, args: [{ op: trim, args: [{ op: coalesce, args: [{ var: arg.text }, ""] }] }] }
  withTax:
    description: The amount with 20% tax added.
    params: [amount]
    body: { op: mul, args: [{ var: arg.amount }, 1.2] }
rules:
  - id: order.total.matches-lines
    kind: validation
    target: { entity: Order, field: /total }
    operations: [create]
    assert:
      op: eq
      args:
        - { var: data.total }
        - { op: sum, args: [{ var: data.items }, { fn: lineTotal, args: [{ var: item.qty }, { var: item.price }] }] }
    severity: error
    finding: { code: LRN-FN-001, message: order.totalMismatch }
  - id: order.reference.required
    kind: validation
    target: { entity: Order, field: /reference }
    operations: [create]
    assert: { op: not, args: [{ fn: isBlank, args: [{ var: data.reference }] }] }
    severity: error
    finding: { code: LRN-FN-002, message: order.referenceRequired }
  - id: order.total.cap
    kind: validation
    target: { entity: Order, field: /total }
    operations: [create]
    assert: { op: lte, args: [{ var: data.total }, { fn: withTax, args: [100] }] }
    severity: error
    finding: { code: LRN-FN-003, message: order.totalCap }
messages:
  en:
    order.totalMismatch: "The total does not match the order lines."
    order.referenceRequired: "Give a reference."
    order.totalCap: "The total may not exceed 120."
tests:
  - name: a wrong total and a blank reference
    entity: Order
    operation: create
    given:
      data: { reference: "  ", total: 12, items: [{ sku: "pen", qty: 2, price: 5 }] }
    expect:
      decision: deny
      findings:
        - { rule: order.total.matches-lines }
        - { rule: order.reference.required }
  - name: a correct order
    entity: Order
    operation: create
    given:
      data: { reference: "A-1", total: 10, items: [{ sku: "pen", qty: 2, price: 5 }] }
    expect: { decision: allow, findings: [] }
  - name: a total above 100 with tax is denied
    entity: Order
    operation: create
    given:
      data: { reference: "A-1", total: 130, items: [{ sku: "pen", qty: 13, price: 10 }] }
    expect:
      decision: deny
      findings:
        - { rule: order.total.cap }
request.json
{
  "entity": "Order",
  "operation": "create",
  "data": {
    "reference": "  ",
    "total": 12,
    "items": [
      {
        "sku": "pen",
        "qty": 2,
        "price": 5
      }
    ]
  }
}

Result, from the engine

Decisiondeny2 findings, server channel

  • LRN-FN-001errorblockingThe total does not match the order lines./total
  • LRN-FN-002errorblockingGive a reference./reference
Course overview

On this page