Rule Cascade
Examples

API endpoint

A payments API that loads the ruleset at start-up and, on every operation, evaluates, refuses with 422, persists, and runs the commands once.

Code: examples/backend-spring-boot (README). CI builds it and runs its MockMvc tests on every push. Every listing on this page is read from that code when the site is built, so it is the code CI runs. The Node.js, Go and Python tabs are the listings of enforcement guide step 4, for the same operation. Runnable Node.js and Go services with the same four steps (evaluate, 422 on deny, persist, run commands once per idempotency key) are in examples/backend-node and examples/backend-go, each tested by CI.

The request flow

Sequence diagram, described in Mermaid: sequenceDiagram participant C as Client participant API as TransferController participant R as RuleSet (server channel) participant S as Store C->>API: POST /transfers {transfer, resolutions} API->>R: evaluate(create, transfer, actor, resolutions) R-->>API: decision, findings, effects, commands alt deny API-->>C: 422 problem details with the evaluation else allow API->>S: save the transfer with the computed values API->>API: run each command once per idempotency key API-->>C: 201 transfer and the non-blocking findings end

Load the ruleset at start-up, and refuse to start on a gap

The service builds one RuleSet when it starts: compiled from the YAML contracts, or read from a bundle compiled in CI when rule-cascade.bundle is set in application.yml. A ruleset that needs a custom operator the service does not register stops the start-up, because at evaluation time such a rule could only fail closed.

From RuleCascadeConfiguration.java:

examples/backend-spring-boot/src/main/java/com/example/payments/RuleCascadeConfiguration.java
RuleSet loaded = bundleLocation.isBlank() ? compile(location, rulesetId) : readBundle(bundleLocation, rulesetId);

// The manifests list the custom operators their rules need. Refuse to start when one is missing:
// at evaluation time a rule that calls an unregistered operator can only fail closed.
RuleSet rules = loaded.withOperators(CustomOperators.all());
List<String> missing = rules.missingOperators();
if (!missing.isEmpty()) {
    throw new IllegalStateException("ruleset " + rules.id() + " needs custom operators that are not registered: " + missing);
}
return rules;

Done when mvn spring-boot:run in examples/backend-spring-boot logs Started PaymentsApplication in 1.125 seconds (the time varies). A ruleset that fails to load or lacks an operator logs the reason and the process exits instead.

Evaluate every operation on the server, and refuse when anything blocks

check builds the EvaluationRequest from the stored record, the actor and the resolutions in the body, evaluates it, and throws RuleViolationException when the decision is deny. ApiExceptionHandler turns that into a 422 ProblemDetail of type urn:rule-cascade:rule-violation with result.toMap() as its evaluation property.

From TransferController.java:

examples/backend-spring-boot/src/main/java/com/example/payments/TransferController.java
private EvaluationResult check(String operation, Map<String, Object> data, Map<String, Object> original,
        String actorId, List<String> roles, Map<String, Object> body) {
    EvaluationRequest request;
    try {
        EvaluationRequest.Builder builder = EvaluationRequest.builder(ENTITY, operation)
                .data(data)
                .original(original)
                .actor(actorId, roles);
        if (body.get("resolutions") instanceof List<?> resolutions) {
            for (Object item : resolutions) {
                Map<?, ?> r = (Map<?, ?>) item;
                builder.resolution(new Resolution((String) r.get("rule"), (String) r.get("type"), (String) r.get("justification")));
            }
        }
        request = builder.build(); // refuses a request that is not well formed, e.g. a resolution without a rule
    } catch (IllegalArgumentException | ClassCastException e) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "malformed resolutions");
    }
    EvaluationResult result = rules.evaluate(request);
    if (!result.allowed()) {
        throw new RuleViolationException(result);
    }
    result.findings().stream()
            .filter(f -> "accepted".equals(f.status()))
            .forEach(f -> log.warn("Risk accepted by {}: {} ({}), ruleset checksum {}", actorId, f.code(), f.rule(), result.checksum()));
    return result;
}

The example reads the actor from two headers to stay short. A real service takes it from its security context.

Persist with the computed values, then run the commands once

When the operation is allowed, the values the compute rules produced (such as the fee) are copied into the record, the record is stored, and each command runs at most once per idempotency key.

examples/backend-spring-boot/src/main/java/com/example/payments/TransferController.java
@PostMapping
public ResponseEntity<Map<String, Object>> create(
        @RequestBody Map<String, Object> body,
        @RequestHeader(value = "X-Actor-Id", defaultValue = "anonymous") String actorId,
        @RequestHeader(value = "X-Actor-Roles", defaultValue = "") List<String> roles) {
    Map<String, Object> transfer = new LinkedHashMap<>(entity(body));
    transfer.put("id", UUID.randomUUID().toString());
    transfer.put("status", "draft");
    transfer.put("createdBy", actorId);

    EvaluationResult result = check("create", transfer, null, actorId, roles, body);
    applyComputedValues(transfer, result);
    store.put((String) transfer.get("id"), transfer);
    run(result);
    return ResponseEntity.status(HttpStatus.CREATED).body(envelope(transfer, result));
}
examples/backend-spring-boot/src/main/java/com/example/payments/TransferController.java
/** 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.
        }
    }
}

Try it

Start the service, then send three transfers:

mvn -f packages/java/pom.xml install
cd examples/backend-spring-boot && mvn spring-boot:run

A domestic transfer without a memo is created, with a non-blocking warning:

curl -s localhost:8080/transfers -H 'Content-Type: application/json' -d '{
  "transfer": {"type": "domestic", "amount": 120.50, "currency": "USD",
               "beneficiary": {"name": "Jo Lee", "country": "US"}}}'
{"transfer":{"type":"domestic","amount":120.50,"currency":"USD","beneficiary":{"name":"Jo Lee","country":"US"},"id":"e8e1a0d5-962d-43e3-9bd2-5f7e5d80b87a","status":"draft","createdBy":"anonymous"},"evaluation":{"ruleset":"acme.payments.transfer","version":"1.0.0","checksum":"sha256:c192dd53b5b1d307d52ccbc27fc1674114e8714d53b699b24088a648ae242c7e","decision":"allow","findings":[{"rule":"org.transfer.memo-recommended","code":"ORG-TRF-003","severity":"warning","message":"Adding a memo makes this transfer easier to reconcile.","fields":["/memo"],"location":{"component":"details-panel"},"blocking":false,"status":"open","resolution":"none","source":"acme.org.base@1.2.0"}],"effects":[],"commands":[]}}

A transfer to a blocked country is refused by a server-only rule the browser never sees:

curl -s localhost:8080/transfers -H 'Content-Type: application/json' -d '{
  "transfer": {"type": "international", "amount": 500, "currency": "USD", "memo": "gift",
               "beneficiary": {"name": "X", "country": "KP", "swiftCode": "ABCDKPPY"}}}'
{"type":"urn:rule-cascade:rule-violation","title":"Business rule violation","status":422,"detail":"Denied by 1 blocking finding(s)","instance":"/transfers","evaluation":{"ruleset":"acme.payments.transfer","version":"1.0.0","checksum":"sha256:c192dd53b5b1d307d52ccbc27fc1674114e8714d53b699b24088a648ae242c7e","decision":"deny","findings":[{"rule":"org.transfer.blocked-country","code":"ORG-TRF-001","severity":"error","message":"Transfers to KP are not permitted.","fields":["/beneficiary/country"],"blocking":true,"status":"open","resolution":"none","source":"acme.org.base@1.2.0"}],"effects":[{"type":"value","field":"/fee","value":7.5,"rule":"transfer.fee.international"}],"commands":[]}}

A risk officer accepts a transfer over the 25000 limit with a justification. It is created, the acceptance is logged, and the large-transfer event runs once:

curl -s localhost:8080/transfers -H 'Content-Type: application/json' \
  -H 'X-Actor-Id: u-9' -H 'X-Actor-Roles: risk-officer' -d '{
  "transfer": {"type": "domestic", "amount": 30000, "currency": "USD", "memo": "house",
               "beneficiary": {"name": "Sam", "country": "US"}},
  "resolutions": [
    {"rule": "transfer.large.review-warning", "type": "acknowledge"},
    {"rule": "org.transfer.amount-limit", "type": "accept-risk", "justification": "verified source of funds"}]}'
WARN  TransferController : Risk accepted by u-9: ORG-TRF-002 (org.transfer.amount-limit), ruleset checksum sha256:c192dd53b5b1d307d52ccbc27fc1674114e8714d53b699b24088a648ae242c7e
INFO  TransferController : Command risk.large-transfer-created -> com.acme.payments.transfer.large.v1 {transferId=8b6afc06-86c4-4adb-b221-243b59f9a4f6, amount=30000}

(The service log, with the timestamps and thread names left out. The ids are random.)

Done when the first request answers 201 with ORG-TRF-003, the second 422 with ORG-TRF-001 in evaluation.findings, and the third 201 with one Command line in the log. TransferApiTest.aBlockedCountryIsRefusedWithProblemDetails asserts the second in CI.

The same step in other languages

Enforcement guide, step 4 (Node.js)
export async function createTransfer(body: CreateBody, caller: Caller) {
  const transfer: JsonObject = { ...body.transfer, id: randomUUID(), status: 'draft' };
  const result = rules.evaluate({                  // throws RequestError when malformed: answer 400
    entity: 'Transfer',
    operation: 'create',
    data: transfer,
    original: null,                                 // update, delete, actions: the stored record
    actor: { id: caller.id, roles: caller.roles },  // from the verified token, never from the body
    ctx: { now: new Date().toISOString() },         // the server clock
    resolutions: body.resolutions,
  }, 'server', operators);
  const { ruleset, version, checksum, decision } = result;
  const findings = result.findings.map((f) => `${f.code}:${f.status}`);
  log.info({ ruleset, version, checksum, decision, findings, actor: caller.id });

  if (decision === 'deny') {
    const problem = { type: 'urn:rule-cascade:rule-violation', title: 'Business rule violation' };
    return { status: 422, body: { ...problem, status: 422, evaluation: result } };
  }
  for (const [pointer, value] of Object.entries(computedValues(result))) {
    if (pointer.lastIndexOf('/') === 0) transfer[pointer.slice(1)] = value;   // such as /fee
  }
  await store.transaction(async (tx) => {
    await tx.save(transfer);
    for (const command of result.commands) await tx.outbox(command.idempotencyKey, command);
  });
  return { status: 201, body: { transfer, evaluation: result } };  // with the non-blocking findings
}

Step by step: Enforce in a backend API.

On this page