Rule Cascade
Examples

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.

Source: site/content/docs/examples/post-save-commands.mdx

On this page