Bindings for language models
Two JSON files let a language-model agent work with Rule Cascade without being trusted with the rules. The agent can ask which rules apply, check an operation before it performs it, and draft a new rule.
Two JSON files let a language-model agent work with Rule Cascade without being trusted with the rules. The agent can ask which rules apply, check an operation before it performs it, and draft a new rule. It can never decide an operation by itself, and a rule it drafts is not a rule until the same checks every hand-written rule passes have passed.
| File | What it is | Use it for |
|---|---|---|
llm-tools.json | Three function tools: list_rules, evaluate_rules, explain_rule | An agent that proposes or performs operations on entities the rules govern |
rule-draft.response-format.json | A structured-output format for one draft validation rule | An authoring assistant that turns a sentence into a rule for review |
Both are written for OpenAI strict mode: every object has additionalProperties: false, every
property is listed in required, optional values are nullable, and only the keywords type,
properties, required, additionalProperties, items, enum, anyOf, $ref, $defs and
description are used. Each file starts with a $comment for readers. It is not part of either
format: send the tools array of the first file, and remove $comment from the second.
Tools
The definitions are in the shape of the OpenAI Responses API. For Chat Completions, wrap each one
as {"type": "function", "function": {name, description, parameters, strict}}. As MCP tools, use
parameters as the inputSchema.
Your program is the tool host: it receives the call, asks a rule server or a runtime, and returns the answer as the tool result.
| Tool | Against the rule server (packages/server) | Against a runtime in process |
|---|---|---|
list_rules | GET /rulesets/{ruleset}/manifest?channel=server, then keep the rules for the entity and operation | The same filter over manifest("server")["rules"] |
evaluate_rules | POST /evaluations | evaluate(request) on the server channel |
explain_rule | GET /rulesets/{ruleset}/rules/{rule_id} | Look the rule up by id in the server manifest |
What the host does with the arguments of evaluate_rules:
data_jsonandoriginal_jsonare JSON text, because a strict tool schema cannot describe an object with arbitrary members. Parse them; anulloriginal_jsonmeans there is no stored state.viewnames one place in the user interface. Drop the members that arenull; with none left, every place is evaluated. A finding reports the place its rule names aslocation(page,screen,section,component), next to the field pointers infields.resolutionsare the acknowledgements and risk acceptances the user has given. Drop anulljustification.- The actor is not an argument. The host takes it from its own authentication, as every host must (specification section 9). A model that could name its own roles could accept its own risks.
A host over the Python runtime, complete enough to run from the
repository root with packages/python/src on PYTHONPATH:
import json
from pathlib import Path
from rule_cascade import RuleSet
rulesets = {p.name[:-len(".bundle.json")]: RuleSet.from_bundle(json.loads(p.read_text(encoding="utf-8")))
for p in Path("conformance/bundles").glob("*.bundle.json")}
def list_rules(ruleset, entity, operation):
return [{"id": r["id"], "kind": r["kind"], "title": r.get("title"), "severity": r.get("severity")}
for r in rulesets[ruleset].manifest("server")["rules"]
if r["target"].get("entity", entity) == entity and (operation is None or operation in r["operations"])]
def evaluate_rules(actor, ruleset, entity, operation, data_json, original_json, view, resolutions):
return rulesets[ruleset].evaluate({
"entity": entity, "operation": operation,
"data": json.loads(data_json),
"original": json.loads(original_json) if original_json else None,
"view": {k: v for k, v in view.items() if v is not None},
"resolutions": [{k: v for k, v in r.items() if v is not None} for r in resolutions],
"actor": actor, # from the host's authentication, never from the model
})
def explain_rule(ruleset, rule_id):
return next((r for r in rulesets[ruleset].manifest("server")["rules"] if r["id"] == rule_id), None)print(list_rules("acme.payments.transfer", "Transfer", "delete"))
# [{'id': 'transfer.delete.only-draft', 'kind': 'validation', 'title': 'Only draft transfers can be deleted', 'severity': 'error'}]Things to keep true in the host:
evaluate_ruleschecks; it does not perform. The API operation that follows evaluates again on the server, with the server manifest, whatever the tool returned.- Return the evaluation result unchanged.
decision,blocking,resolutionandacceptableBytell the model what the user may do next;checksumsays which rules answered. - A resolution comes from the user. Ask the user before sending
acknowledge, and never let the model write the justification of anaccept-risk. - The server manifest contains server-only rules. If the model's output is shown to people who must
not learn them, serve
list_rulesandexplain_rulefrom the client manifest instead. - A ruleset with custom operators needs them registered in the host's runtime; the
rule-cascade-servercommand registers none, so those rules fail closed there.
Drafting a rule
rule-draft.response-format.json constrains a model to produce one validation rule: id, title,
target, operations, enforcement, an optional when, assert, severity, finding code and message
key, and the English message text. Expressions are the real expression grammar: literals,
{var}, {op, args} with the 45 core operators, and {fn, args}.
Before sending the format for a particular ruleset, you may narrow it to that ruleset's vocabulary:
- add the names of the custom operators the ruleset declares (
x-...) to theopenum; - replace the type of
fnwith anenumof the function names the ruleset declares.
Then turn the draft into a rule:
-
Remove the members that are
null, addkind: validation, and takemessage_textout:import json import sys draft = json.load(sys.stdin) rule = {"kind": "validation", **{k: v for k, v in draft.items() if v is not None and k != "message_text"}} rule["target"] = {k: v for k, v in draft["target"].items() if v is not None} json.dump({"rule": rule, "messages": {draft["finding"]["message"]: draft["message_text"]}}, sys.stdout, indent=2) -
Add the rule to
rulesand the message tomessages.<defaultLocale>of the ruleset. JSON is valid YAML, so the output can be pasted into a YAML ruleset as it is. -
Write at least one golden test for it under
tests: an input that violates the rule and the finding you expect. -
Run the linter, then open a pull request like for any other rule:
python tools/rulecheck.py check path/to/your.ruleset.yaml
What a draft cannot skip
Strict output guarantees the shape of the draft and nothing else. The draft still has to pass:
| Check | Catches |
|---|---|
Ruleset schema (SCHEMA_INVALID) | An id, finding code or pointer that is not well formed; a target with neither entity nor type |
| Load-time checks (specification section 5) | A path that is not in the entity schema (PATH_UNKNOWN), an undeclared parameter, function or custom operator, a pattern outside the portable subset (PATTERN_NOT_PORTABLE), an expression nested more than 128 deep (EXPRESSION_TOO_DEEP), a rule id or finding code already in use, original.* on create, a missing message |
| Golden tests | A rule that loads but does not mean what was asked |
| Review | Whether the rule should exist; .github/CODEOWNERS routes rulesets to their owners |
For example, a draft that reads data.memoo and uses \s in a pattern is refused:
PATTERN_NOT_PORTABLE: transfer.memo.max-length rule: pattern '^\\s*$': escape \s is not portable
PATH_UNKNOWN: transfer.memo.max-length path data.memoo is not in the Transfer schemaLimits of the draft format
- It drafts
validationrules. State, compute and action rules are written by hand. - It has no
finding.args,triggers,forEach,acknowledgementoracceptance: a strict schema cannot describe the free-form map offinding.args, and the others are decisions for the author. Add them by hand;message_textmust therefore not contain{placeholders}. - The model does not know your entity schema unless you put it in the prompt. Give it the schema, the declared parameters and functions, and two or three existing rules of the ruleset; the authoring guidelines are written to be usable as instructions.
Keeping the files current
python tools/lint_specs.py fails when the op enum of the response format differs from the
operator enum of spec/v1/rule-cascade.schema.json. The tool
definitions follow the evaluation request of specification section 8 and the wire API in
spec/v1/rule-evaluation.openapi.yaml; update them by
hand when a request member is added.
Rendered from bindings/README.md in the repository. Edit it there.
One rule language for every programming language and operating system
This page explains how Rule Cascade gives the same answer in every language and on every operating system, which form of the engine to use where, and how to add a language that has no runtime yet.
Kubernetes deployment
Three manifests run the rule server as a highly available, autoscaled service.