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
-
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}` }); -
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}> -
See the difference between the channels with the sample rules. On the server, an allowed order returns the
order.placedcommand; 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
- Backend API and React form playbooks.
- API endpoint and UI form examples.
Load in applications
Embed a runtime and load the bundle at start-up, or let the application ask the rule server. Either way, log the checksum.
Roll out and roll back
Swap the rules a server holds without a restart. A broken ruleset is refused and the old one keeps serving. Rolling back is serving the old bundle again.