Rule Cascade in Go
Module: go get rulescascade.com/go (Go 1.26+, no dependencies beyond the standard library and
gopkg.in/yaml.v3). Bundles come from rcas compile --all into build/rules/. Never evaluate the YAML
at run time and never copy a rule into code.
Load once, evaluate per request
import rulecascade "rulescascade.com/go"
// At start-up. Any error: do not start.
data, err := os.ReadFile("build/rules/<ruleset-id>.bundle.json")
doc, err := rulecascade.ParseJSON(data)
rules, err := rulecascade.FromBundle(doc)
Prefer the middleware (from 1.0.0-alpha.7): rulescascade.com/go/rulehttp builds the request
from the JSON body (which stays readable), enforces, and answers a denied request with the 422
problem details below, the same in every Rule Cascade runtime.
mux.Handle("POST /orders", rulehttp.Enforce(rules, rulehttp.Options{Entity: "Order", Operation: "create",
Actor: func(r *http.Request) map[string]any { return actorFromToken(r) }})(createOrder))
// in the handler: rulehttp.FromContext(r.Context()) is the allowed result
Elsewhere, rules.Enforce(request, "server", nil) returns a *rulecascade.RuleViolation error
(Problem() is the body) and rulehttp.WriteError(w, err) answers it. Underneath,
rules.Evaluate(request, "server", nil) returns a *rulecascade.Result (Decision, Findings,
Effects, Commands; result.Allowed()). A *RuleSet is safe for concurrent use. Every
state-changing handler follows four steps:
- Evaluate on the server with
map[string]any{"entity", "operation", "data", "original", "actor", "resolutions"}. The actor comes from authentication middleware, never from a header the caller controls. Decode request bodies withrulecascade.ParseJSON(orjson.Decoder.UseNumber()) so numbers stay exact. - Refuse on deny with 422
application/problem+json(type: urn:rule-cascade:rule-violation) carrying the result; for gRPC,codes.FailedPreconditionwith the findings in the details. - Persist, after applying
computeeffects (Type == "value"): the server's value is stored. - Run
result.Commandsafter the commit, once perIdempotencyKey(an outbox table, or a broker with deduplication).
Placement: one function per entity that builds the request and evaluates, called from every handler
(net/http, chi, gin, echo all the same). rulecascade.NewHolder reloads bundles on an interval or a
cron schedule and keeps the last good rules.
Replacing existing validation
rcas analyze --module <module> lists validate: and binding: struct tags, ozzo-validation, rule
engines, hand-written errors.New checks, named limits (const MaxQuantity = 10), comparisons with
numbers and switch branches. A candidate with drift means another language of the project (often the front end) decides the same name with another value: one ruleset must decide it for both. Keep
shape checks in decoding; move business decisions to rules. Add a table-driven parity test over the
same inputs for the old check and the engine, then delete the old check.
Tests
Table-driven tests on Decision and finding Codes, not message texts. Golden tests in the
ruleset (rcas check) test the rules; CI runs rcas check and rcas compile --all before go test.
Reference: https://rulescascade.com/usage/go/