Rule Cascade
Reference

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.

FileWhat it isUse it for
llm-tools.jsonThree function tools: list_rules, evaluate_rules, explain_ruleAn agent that proposes or performs operations on entities the rules govern
rule-draft.response-format.jsonA structured-output format for one draft validation ruleAn 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.

ToolAgainst the rule server (packages/server)Against a runtime in process
list_rulesGET /rulesets/{ruleset}/manifest?channel=server, then keep the rules for the entity and operationThe same filter over manifest("server")["rules"]
evaluate_rulesPOST /evaluationsevaluate(request) on the server channel
explain_ruleGET /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_json and original_json are JSON text, because a strict tool schema cannot describe an object with arbitrary members. Parse them; a null original_json means there is no stored state.
  • view names one place in the user interface. Drop the members that are null; with none left, every place is evaluated. A finding reports the place its rule names as location (page, screen, section, component), next to the field pointers in fields.
  • resolutions are the acknowledgements and risk acceptances the user has given. Drop a null justification.
  • 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_rules checks; 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, resolution and acceptableBy tell the model what the user may do next; checksum says which rules answered.
  • A resolution comes from the user. Ask the user before sending acknowledge, and never let the model write the justification of an accept-risk.
  • The server manifest contains server-only rules. If the model's output is shown to people who must not learn them, serve list_rules and explain_rule from the client manifest instead.
  • A ruleset with custom operators needs them registered in the host's runtime; the rule-cascade-server command 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 the op enum;
  • replace the type of fn with an enum of the function names the ruleset declares.

Then turn the draft into a rule:

  1. Remove the members that are null, add kind: validation, and take message_text out:

    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)
  2. Add the rule to rules and the message to messages.<defaultLocale> of the ruleset. JSON is valid YAML, so the output can be pasted into a YAML ruleset as it is.

  3. Write at least one golden test for it under tests: an input that violates the rule and the finding you expect.

  4. 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:

CheckCatches
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 testsA rule that loads but does not mean what was asked
ReviewWhether 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 schema

Limits of the draft format

  • It drafts validation rules. State, compute and action rules are written by hand.
  • It has no finding.args, triggers, forEach, acknowledgement or acceptance: a strict schema cannot describe the free-form map of finding.args, and the others are decisions for the author. Add them by hand; message_text must 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.

On this page