Rule Cascade
LearnEffects

Commands

Action rules ask the host to do something after an allowed change is saved.

An action rule returns commands: an event to publish or an operation to call. The engine only returns them, and only on the server and only when the decision is allow. The host runs them after it saved the change, and de-duplicates them on the idempotencyKey, so a retry never sends a command twice.

Syntax

an action rule
- id: <rule id>
  kind: action
  target: { entity: Order }
  operations: [create]
  enforcement: server            # action rules are always server-only
  commands:
    - name: order.confirmation-requested
      type: event                 # or operation
      ref: OrderConfirmed         # event type or OpenAPI operationId
      payload: { orderId: { var: data.id } }
      idempotencyKey: ["order-confirmation", { var: data.id }]

Example

An allowed order asks for a confirmation e-mail and an invoice. The golden tests also show that a denied order, and the client channel, return no commands.

commands.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.commands, version: 1.0.0, title: "Commands" }
scope:
  - { level: organization, id: learn }
entities:
  Order:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
rules:
  - id: order.email.required
    kind: validation
    target: { entity: Order, field: /email }
    operations: [create]
    assert: { op: exists, args: [{ var: data.email }] }
    severity: error
    finding: { code: LRN-CMD-001, message: order.emailRequired }
  - id: order.after-create
    kind: action
    target: { entity: Order }
    operations: [create]
    enforcement: server
    commands:
      - name: order.confirmation-requested
        type: event
        ref: OrderConfirmed
        payload: { orderId: { var: data.id }, email: { var: data.email } }
        idempotencyKey: ["order-confirmation", { var: data.id }]
      - name: order.invoice-requested
        type: operation
        ref: createInvoice
        payload: { orderId: { var: data.id } }
        idempotencyKey: ["invoice", { var: data.id }]
messages:
  en:
    order.emailRequired: "Give an e-mail address for the confirmation."
tests:
  - name: an allowed order asks for a confirmation and an invoice
    entity: Order
    operation: create
    given:
      data: { id: "o-1", email: "ana@example.com" }
    expect:
      decision: allow
      findings: []
      commands: [order.confirmation-requested, order.invoice-requested]
  - name: a denied order sends nothing
    entity: Order
    operation: create
    given:
      data: { id: "o-1" }
    expect:
      decision: deny
      findings:
        - { rule: order.email.required }
      commands: []
  - name: the client channel never returns commands
    entity: Order
    operation: create
    channel: client
    given:
      data: { id: "o-1", email: "ana@example.com" }
    expect:
      decision: allow
      findings: []
      commands: []
request.json
{
  "entity": "Order",
  "operation": "create",
  "data": {
    "id": "o-1",
    "email": "ana@example.com"
  }
}

Result, from the engine

Decisionallow0 findings, server channel

  • command order.confirmation-requested (event), idempotency key order-confirmation:o-1
  • command order.invoice-requested (operation), idempotency key invoice:o-1
Try it YourselfOpens this ruleset and request in the playground. Nothing to install.

Common mistakes

  • Running commands before the change is saved. If the save fails, nothing should be announced.
  • An idempotency key that changes between retries, such as one with a timestamp. Build it from stable ids.

Exercise

Add a command order.packing-requested (an operation with ref: startPacking) that is returned only for express orders. Test an express order and a standard one.

Hint

Add a second action rule whose when checks that data.express equals true, and one command of type operation, ref startPacking.

Show answer
commands.ruleset.yaml
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.commands, version: 1.0.0, title: "Commands" }
scope:
  - { level: organization, id: learn }
entities:
  Order:
    schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
rules:
  - id: order.email.required
    kind: validation
    target: { entity: Order, field: /email }
    operations: [create]
    assert: { op: exists, args: [{ var: data.email }] }
    severity: error
    finding: { code: LRN-CMD-001, message: order.emailRequired }
  - id: order.after-create
    kind: action
    target: { entity: Order }
    operations: [create]
    enforcement: server
    commands:
      - name: order.confirmation-requested
        type: event
        ref: OrderConfirmed
        payload: { orderId: { var: data.id }, email: { var: data.email } }
        idempotencyKey: ["order-confirmation", { var: data.id }]
      - name: order.invoice-requested
        type: operation
        ref: createInvoice
        payload: { orderId: { var: data.id } }
        idempotencyKey: ["invoice", { var: data.id }]
  - id: order.express.packing
    kind: action
    target: { entity: Order }
    operations: [create]
    enforcement: server
    when: { op: eq, args: [{ var: data.express }, true] }
    commands:
      - name: order.packing-requested
        type: operation
        ref: startPacking
        payload: { orderId: { var: data.id } }
        idempotencyKey: ["packing", { var: data.id }]
messages:
  en:
    order.emailRequired: "Give an e-mail address for the confirmation."
tests:
  - name: an allowed order asks for a confirmation and an invoice
    entity: Order
    operation: create
    given:
      data: { id: "o-1", email: "ana@example.com", express: true }
    expect:
      decision: allow
      findings: []
      commands: [order.confirmation-requested, order.invoice-requested, order.packing-requested]
  - name: a denied order sends nothing
    entity: Order
    operation: create
    given:
      data: { id: "o-1", express: true }
    expect:
      decision: deny
      findings:
        - { rule: order.email.required }
      commands: []
  - name: the client channel never returns commands
    entity: Order
    operation: create
    channel: client
    given:
      data: { id: "o-1", email: "ana@example.com", express: true }
    expect:
      decision: allow
      findings: []
      commands: []
  - name: no packing command without express delivery
    entity: Order
    operation: create
    given:
      data: { id: "o-2", email: "ana@example.com" }
    expect:
      decision: allow
      findings: []
      commands: [order.confirmation-requested, order.invoice-requested]
request.json
{
  "entity": "Order",
  "operation": "create",
  "data": {
    "id": "o-1",
    "email": "ana@example.com",
    "express": true
  }
}

Result, from the engine

Decisionallow0 findings, server channel

  • command order.confirmation-requested (event), idempotency key order-confirmation:o-1
  • command order.invoice-requested (operation), idempotency key invoice:o-1
  • command order.packing-requested (operation), idempotency key packing:o-1
Course overview

On this page