Rules from existing code
An engineering playbook for moving business rules out of application code into rulesets, safely, in any language, with or without an AI agent.
Outcome: the decisions your services and front ends make (what is valid, what needs approval, what is computed, what happens next) live in reviewed, tested rulesets, evaluated identically in every language, and the copies in code are gone.
Prerequisites: rcas installed, and the project
initialized from its code (rcas init --from .).
1. Find the decisions
Start from the inventory (rcas analyze, or .rcas/analysis.md). For each hit, read enough code to
write one sentence: on entity, during operation, field or relation, what is allowed, what
happens otherwise. Then sort it:
| What you found | Belongs in | Why |
|---|---|---|
| A constraint the API schema states (required, enum, length, pattern, range) | the derived ruleset (rcas derive) | Generated from the schema, so it never drifts from it |
| A rule across fields, on a state transition, against a limit, or with a warning or an approval | a hand-written ruleset | This is what rulesets are for |
| A computed value (a fee, a due date, a risk score from facts) | a compute rule | Same answer in the form and the API |
| A follow-up (an event, a notification) that depends on the decision | an action rule with commands | The host executes; the rule decides |
| Shape and transport: types, formats, sizes, authentication, rate limits | stays in the API layer | Rules assume valid shapes; see enforcement guide step 7 |
| A check of the program itself (a file exists, a connection is open) | stays in code | It is not a business decision |
The rcas-analyst agent does this sorting and cites file:line for every decision.
2. Write each rule to be reviewed
These are the rules a reviewer checks (authoring guidelines
has the full list with the codes check reports):
- One rule, one concern. One condition on one field or one relation, one finding code. "Positive and below the limit" is two rules.
- Names are permanent. Rule ids
<entity>.<field or aspect>.<constraint>(transfer.amount.positive), finding codes<DOMAIN>-<GROUP>-<NNN>(PAY-TRF-002). Never reuse or repurpose one; retire it and add a new one (naming conventions). - Guard what may be missing. A rule that reads an optional field has a
whenthat checks it exists. Otherwise a missing value is an evaluation error, not a finding the user can act on. - Limits are parameters. Thresholds, lists and amounts are
paramswith an override policy:lockedfor law and integrity,tighten-onlyfor limits,openfor defaults. A business unit can then tighten a limit in a child ruleset without copying the rule. - Severity is a decision.
errorblocks,warningwarns and can require an acknowledgement,infoinforms. Risk acceptance names the roles that may accept; integrity and legal rules are never acceptable. - The server decides. Every rule is enforced on the server. Mark a rule
enforcement: bothonly when everything it reads may be public: the client manifest is readable by anyone. - Messages are keys, with arguments, translated per locale in
messages. No text in code. - Portable expressions. Operators from the specification, patterns from the portable subset (anchored, character classes, bounded counts).
- Version like an API (guideline 1.5):
adding an
errorrule, raising a severity, tightening a parameter, or removing or renaming anything is major; a warning without acknowledgement or acomputerule is minor; aninforule or a reworded message is a patch.
3. Test every rule
Golden tests are the specification of the rule; reviewers read them first. For each rule:
| Case | Example |
|---|---|
| passes | 3 items: allow, no finding |
| fails | 11 items: deny, the finding, its field and message |
| boundary | exactly 10 items: allow |
| missing optional data | no quantity: the guard skips the rule |
| client channel | when the rule runs in the browser, the same case with channel: client |
rcas check runs them in CI. A rule without tests is reported by the MCP tools, and the reviewer
should not accept it.
4. Propose, review, accept
Changes reach the rules through review, never directly:
rcas derive api/openapi.yaml --schema Transfer --id acme.payments.transfer-generated --propose
rcas proposals show derive-acme-payments-transfer-generated --diff
rcas proposals accept derive-acme-payments-transfer-generated
rcas check && git add rules/ && git commitAn agent submits with propose_ruleset; the proposal records its rationale and the code it came from.
The reviewer uses rcas-reviewer, or the checklist in
authoring guidelines section 12.
5. Replace the code, one rule at a time
- Shadow. Evaluate the ruleset next to the existing check and log disagreements. Change no behaviour.
- Parity test. A test in the project's own framework feeds the same inputs to the old code path and to the engine, and compares decisions. Keep it until step 4.
- Switch. Enforce the engine's decision on the backend; show its findings in the form.
- Delete the old check and the parity test. One source of truth remains.
Do this per rule or per entity, not for the whole system at once. A rollback is a revert of the ruleset (or of the bundle version a service loads); the code check is still there until step 4.
6. Keep it language-agnostic
- Rules are data. Nothing in a ruleset depends on the language, framework or operating system of a service: the same bundle is evaluated by TypeScript, Python, Java and Go, by the command and by WebAssembly, identically (the conformance suite proves it on Linux, macOS and Windows).
- Compile once in CI (
rcas compile --all), ship bundles as build artefacts, load them at start-up. - A language without a runtime uses
rcas engineorrcas.wasm: one child process or one module, no port needed. - Front ends get the client manifest, never the bundle.
Done when
- every decision in the inventory is either a rule, or noted as staying in code with a reason;
rcas checkpasses with golden tests for every rule, and CI runs it;- each replaced check in code is deleted, with its parity test.