Rule Cascade
LearnRule basics

Operations and when

Run a rule only for some operations, and only when a condition holds. Compare with the stored state.

operations lists the operations a rule applies to: create, read, update, delete, list, or any business action you name, such as cancel or approve. when is a guard: while it is false, the rule does not run at all. On an update, original holds the stored state and data the proposed one, so a rule can compare them.

Syntax

operations and when
operations: [update, cancel]                  # CRUD verbs or your own action names
when: { op: eq, args: [{ var: original.status }, shipped] }   # optional guard

A request names its operation, and an update sends the stored state as original:

request.json
{ "entity": "Order", "operation": "update", "original": { "status": "shipped" }, "data": { "status": "open" } }

Example

The first rule only runs on updates of an order that was already shipped. The second rule only runs for the custom action cancel.

operations-and-when.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.operations-and-when, version: 1.0.0, title: "Operations and when" }
scope:
  - { level: organization, id: learn }
entities:
  Order:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
rules:
  - id: order.shipped.final
    kind: validation
    target: { entity: Order, field: /status }
    operations: [update]
    when: { op: eq, args: [{ var: original.status }, shipped] }
    assert: { op: eq, args: [{ var: data.status }, shipped] }
    severity: error
    finding: { code: LRN-OPS-001, message: order.shippedIsFinal }
  - id: order.cancel.reason
    kind: validation
    target: { entity: Order, field: /cancelReason }
    operations: [cancel]
    assert: { op: exists, args: [{ var: data.cancelReason }] }
    severity: error
    finding: { code: LRN-OPS-002, message: order.cancelReasonMissing }
messages:
  en:
    order.shippedIsFinal: "A shipped order cannot change its status."
    order.cancelReasonMissing: "Say why the order is cancelled."
tests:
  - name: a shipped order cannot go back to open
    entity: Order
    operation: update
    given:
      original: { id: o-1, status: shipped }
      data: { id: o-1, status: open }
    expect:
      decision: deny
      findings:
        - { rule: order.shipped.final, fields: [/status] }
  - name: an open order may change its status
    entity: Order
    operation: update
    given:
      original: { id: o-1, status: open }
      data: { id: o-1, status: packed }
    expect: { decision: allow, findings: [] }
  - name: cancelling needs a reason
    entity: Order
    operation: cancel
    given:
      original: { id: o-1, status: open }
      data: { id: o-1, status: open }
    expect:
      decision: deny
      findings:
        - { rule: order.cancel.reason, fields: [/cancelReason] }
request.json
{
  "entity": "Order",
  "operation": "update",
  "original": {
    "id": "o-1",
    "status": "shipped"
  },
  "data": {
    "id": "o-1",
    "status": "open"
  }
}

Result, from the engine

Decisiondeny1 finding, server channel

  • LRN-OPS-001errorblockingA shipped order cannot change its status./status
Try it YourselfOpens this ruleset and request in the playground. Nothing to install.

Common mistakes

  • Reading original in a rule that applies to create. There is no stored state on create, so the compiler refuses it with ORIGINAL_ON_CREATE:
operations-and-when-fix.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.operations-fix, version: 1.0.0, title: "Operations, fix the create rule" }
scope:
  - { level: organization, id: learn }
entities:
  Order:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
rules:
  - id: order.shipped.final
    kind: validation
    target: { entity: Order, field: /status }
    operations: [create, update]
    when: { op: eq, args: [{ var: original.status }, shipped] }
    assert: { op: eq, args: [{ var: data.status }, shipped] }
    severity: error
    finding: { code: LRN-OPS-001, message: order.shippedIsFinal }
  - id: order.cancel.reason
    kind: validation
    target: { entity: Order, field: /cancelReason }
    operations: [cancel]
    assert: { op: exists, args: [{ var: data.cancelReason }] }
    severity: error
    finding: { code: LRN-OPS-002, message: order.cancelReasonMissing }
messages:
  en:
    order.shippedIsFinal: "A shipped order cannot change its status."
    order.cancelReasonMissing: "Say why the order is cancelled."
tests:
  - name: a shipped order cannot go back to open
    entity: Order
    operation: update
    given:
      original: { id: o-1, status: shipped }
      data: { id: o-1, status: open }
    expect:
      decision: deny
      findings:
        - { rule: order.shipped.final, fields: [/status] }
  - name: an open order may change its status
    entity: Order
    operation: update
    given:
      original: { id: o-1, status: open }
      data: { id: o-1, status: packed }
    expect: { decision: allow, findings: [] }
  - name: cancelling needs a reason
    entity: Order
    operation: cancel
    given:
      original: { id: o-1, status: open }
      data: { id: o-1, status: open }
    expect:
      decision: deny
      findings:
        - { rule: order.cancel.reason, fields: [/cancelReason] }
request.json
{
  "entity": "Order",
  "operation": "update",
  "original": {
    "id": "o-1",
    "status": "shipped"
  },
  "data": {
    "id": "o-1",
    "status": "open"
  }
}

Result, from the engine

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

  • ORIGINAL_ON_CREATE original.* is used but the rule applies to create (line 14)
Try it YourselfOpens this ruleset and request in the playground. Nothing to install.
  • Putting the condition in assert instead of when. A false assert is a finding; a false when means the rule does not apply.

Exercise

Add a rule: a shipped order cannot be cancelled. Use the code LRN-OPS-003 and the message "A shipped order cannot be cancelled." Test it with a cancel request whose original.status is shipped.

Hint

Add a validation rule for operations [cancel] whose assert compares original.status with shipped using ne.

Show answer
operations-and-when.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.operations-and-when, version: 1.0.0, title: "Operations and when" }
scope:
  - { level: organization, id: learn }
entities:
  Order:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
rules:
  - id: order.shipped.final
    kind: validation
    target: { entity: Order, field: /status }
    operations: [update]
    when: { op: eq, args: [{ var: original.status }, shipped] }
    assert: { op: eq, args: [{ var: data.status }, shipped] }
    severity: error
    finding: { code: LRN-OPS-001, message: order.shippedIsFinal }
  - id: order.cancel.reason
    kind: validation
    target: { entity: Order, field: /cancelReason }
    operations: [cancel]
    assert: { op: exists, args: [{ var: data.cancelReason }] }
    severity: error
    finding: { code: LRN-OPS-002, message: order.cancelReasonMissing }
  - id: order.cancel.shipped
    kind: validation
    target: { entity: Order, field: /status }
    operations: [cancel]
    assert: { op: ne, args: [{ var: original.status }, shipped] }
    severity: error
    finding: { code: LRN-OPS-003, message: order.cannotCancelShipped }
messages:
  en:
    order.shippedIsFinal: "A shipped order cannot change its status."
    order.cancelReasonMissing: "Say why the order is cancelled."
    order.cannotCancelShipped: "A shipped order cannot be cancelled."
tests:
  - name: cancelling a shipped order with a reason is still denied
    entity: Order
    operation: cancel
    given:
      original: { id: o-1, status: shipped }
      data: { id: o-1, status: shipped, cancelReason: lost in transit }
    expect:
      decision: deny
      findings:
        - { rule: order.cancel.shipped, fields: [/status] }
  - name: a shipped order cannot go back to open
    entity: Order
    operation: update
    given:
      original: { id: o-1, status: shipped }
      data: { id: o-1, status: open }
    expect:
      decision: deny
      findings:
        - { rule: order.shipped.final, fields: [/status] }
  - name: an open order may change its status
    entity: Order
    operation: update
    given:
      original: { id: o-1, status: open }
      data: { id: o-1, status: packed }
    expect: { decision: allow, findings: [] }
  - name: cancelling needs a reason
    entity: Order
    operation: cancel
    given:
      original: { id: o-1, status: open }
      data: { id: o-1, status: open }
    expect:
      decision: deny
      findings:
        - { rule: order.cancel.reason, fields: [/cancelReason] }
request.json
{
  "entity": "Order",
  "operation": "cancel",
  "original": {
    "id": "o-1",
    "status": "shipped"
  },
  "data": {
    "id": "o-1",
    "status": "shipped",
    "cancelReason": "lost in transit"
  }
}

Result, from the engine

Decisiondeny1 finding, server channel

  • LRN-OPS-003errorblockingA shipped order cannot be cancelled./status
Course overview

On this page