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
- 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.
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: []{
"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 keyorder-confirmation:o-1 - command
order.invoice-requested(operation), idempotency keyinvoice:o-1
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
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]{
"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 keyorder-confirmation:o-1 - command
order.invoice-requested(operation), idempotency keyinvoice:o-1 - command
order.packing-requested(operation), idempotency keypacking:o-1