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: [update, cancel] # CRUD verbs or your own action names
when: { op: eq, args: [{ var: original.status }, shipped] } # optional guardA request names its operation, and an update sends the stored state as original:
{ "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.
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] }{
"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
Common mistakes
- Reading
originalin a rule that applies tocreate. There is no stored state on create, so the compiler refuses it withORIGINAL_ON_CREATE:
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] }{
"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_CREATEoriginal.* is used but the rule applies to create (line 14)
- Putting the condition in
assertinstead ofwhen. A falseassertis a finding; a falsewhenmeans 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
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] }{
"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