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
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:
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:
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.
@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));
}/** 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:runA 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
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.