Rule Cascade
LearnThe process, end to end

Enforce in the UI and the API

The browser evaluates for feedback; the server evaluates for the decision. A denied operation gets 422 and nothing is saved.

The same rules run twice. In the browser they give feedback while the user types and disable the submit button. On the server they decide: the API evaluates every state-changing request, refuses it with 422 when the decision is deny, saves it otherwise, and only then runs the commands. Never skip the server evaluation because the browser said yes.

Hands-on

  1. In the API, follow four steps for every operation: evaluate on the server, refuse with 422, persist, run the commands once per idempotency key.

    examples/backend-node/src/app.mjs (evaluate, refuse)
    /** Steps 1 and 2: evaluate on the server and refuse when anything blocks. */
    function check(operation, data, original, actor, body) {
      let result;
      try {
        result = rules.evaluate({ entity: ENTITY, operation, data, original, actor, resolutions: body.resolutions ?? null }, 'server');
      } catch (err) {
        // The runtime refuses a request that does not have the shape of specification section 8.
        if (err instanceof RequestError) throw new HttpError(400, err.message);
        throw err;
      }
      if (result.decision !== 'allow') {
        const blocking = result.findings.filter((f) => f.blocking).length;
        throw new HttpError(422, 'Business rule violation', {
          type: 'urn:rule-cascade:rule-violation',
          detail: `Denied by ${blocking} blocking finding(s)`,
          evaluation: result,
        });
      }
      return result;
    }
    examples/backend-node/src/app.mjs (run the commands once)
    /** Step 4: after the change is stored, at most once per idempotency key. */
    function runCommands(result) {
      for (const command of result.commands) {
        if (handledCommands.has(command.idempotencyKey)) continue;
        handledCommands.add(command.idempotencyKey);
        publish(command);
      }
    }
    examples/backend-node/src/app.mjs (the four steps in order)
    // POST /transfers
    if (method === 'POST' && url.pathname === '/transfers') {
      const body = await readJson(req);
      const actor = actorOf(req);
      const transfer = { ...entityOf(body), id: randomUUID(), status: 'draft', createdBy: actor.id };
      const result = check('create', transfer, null, actor, body);
      applyComputedValues(transfer, result);
      store.set(transfer.id, transfer); // step 3
      runCommands(result);
      return json(res, 201, { transfer, evaluation: result }, { Location: `/transfers/${transfer.id}` });
  2. In the browser, evaluate the client manifest on every change and gate the submit button on allowed:

    examples/frontend-react/src/TransferForm.tsx
    const { result, allowed, findings, states, computed, findingsFor } = useRuleEvaluation(manifest, request);
    examples/frontend-react/src/TransferForm.tsx
    <button type="submit" disabled={!allowed}>
  3. See the difference between the channels with the sample rules. On the server, an allowed order returns the order.placed command; on the client channel there are never commands:

    the client channel returns no commands (real output)
    $ rcas compile orders.ruleset.yaml -o shop.orders.bundle.json
    wrote shop.orders.bundle.json  shop.orders@1.0.0  sha256:4583c90bbfcacb54bf78e11d9e8190ecc086ce4b69aade609b7a0928d452ea82
    [exit status 0]
    
    $ rcas evaluate --bundle shop.orders.bundle.json --channel client three-items.json | jq '{decision, findings, commands}'
    {
      "decision": "allow",
      "findings": [],
      "commands": []
    }
    [exit status 0]

Done when

  • A request the rules deny gets 422, and nothing is stored.
  • An allowed request is stored first, then its commands run, at most once per idempotency key.
  • The browser disables submit while a blocking finding is open, and the server still evaluates.

Go deeper

Course overview

On this page