Post-save commands
An action rule that asks the host to publish an event after a large transfer is saved - once, keyed by a deterministic idempotency key.
Files: the action rule in
examples/contracts/payments-transfer.ruleset.yaml
and run in
TransferController.java.
The rule
- id: transfer.created.notify-risk
kind: action
title: Tell risk about large transfers
target: { entity: Transfer }
operations: [create]
enforcement: server
when:
op: and
args:
- { op: exists, args: [ { var: data.amount } ] }
- { op: gte, args: [ { var: data.amount }, { var: params.largeTransferThreshold } ] }
commands:
- name: risk.large-transfer-created
type: event
ref: com.acme.payments.transfer.large.v1
payload:
transferId: { var: data.id }
amount: { var: data.amount }
idempotencyKey: [ large-transfer, { var: data.id } ]An action rule is always server-only: it is never in the client manifest.
What the engine returns
For an acknowledged 12,000 transfer (transfer.large.review-warning acknowledged), the server
evaluation is allow and carries the command:
echo '{"entity":"Transfer","operation":"create",
"data":{"id":"t-4","type":"domestic","amount":12000,"currency":"USD","memo":"car",
"beneficiary":{"name":"Sam","country":"US"}},
"resolutions":[{"rule":"transfer.large.review-warning","type":"acknowledge"}]}' |
rule-cascade evaluate --bundle conformance/bundles/acme.payments.transfer.bundle.json - | jq '.commands'[
{
"name": "risk.large-transfer-created",
"type": "event",
"rule": "transfer.created.notify-risk",
"idempotencyKey": "large-transfer:t-4",
"payload": {
"transferId": "t-4",
"amount": 12000
},
"ref": "com.acme.payments.transfer.large.v1"
}
]The engine returns commands only for an allowed evaluation on the server channel. A denied
operation, or the same request on the client channel, has "commands": []. The golden test
"acknowledged large transfer is allowed and emits the risk event" pins this.
What the host does with it
From TransferController.java: after the change is stored, each command runs at most once per
idempotency key.
/** Step 4: commands run after the change is stored, at most once per idempotency key. */
private void run(EvaluationResult result) {
for (EvaluationResult.Command command : result.commands()) {
if (handledCommands.putIfAbsent(command.idempotencyKey(), Boolean.TRUE) == null) {
log.info("Command {} -> {} {}", command.name(), command.ref(), command.payload());
// Publish to your broker or call the bound operation here.
}
}
}The example keeps the handled keys in memory to stay short. A service writes the commands to an
outbox table in the same transaction as the change, and a relay publishes them after the commit,
de-duplicating on idempotencyKey
(enforcement guide step 4, items 5
and 6). Use a string identifier in the key: numbers that differ beyond 15 significant digits
collapse into one key.
Level cascade
An organisation baseline and a retail feature ruleset that extends it, tightens a limit and raises a severity - and what the result looks like.
Other languages
The same two evaluations from Python, Node.js, Ruby, PHP, Java, shell, Rust, C# and PowerShell through the command, and from Node.js and Python through the WebAssembly module.