ADR 0008: Reuse through functions in the contract; escape through host-supplied operators
Status: accepted
Status: accepted
Context
Rulesets repeated the same sub-expressions: "blank or missing", "age on a date". And some checks cannot be written with the core operators at all, such as a check digit or a lookup the host owns. The two needs look alike and are not: the first is reuse of logic that is already portable, the second is logic that leaves the contract.
Decision
Functions are declared in the ruleset under functions: a name, parameter names and a body that
is an expression. A call { "fn": name, "args": [...] } evaluates its arguments in the caller's
scope, then the body with arg.<param> bound. Scope is lexical: a body sees data, original,
actor, ctx, params and its own arg, never the caller's item, value, field or arg.
Arity is checked at load. Recursion, direct or indirect, is rejected at load (FUNCTION_RECURSIVE),
and a call nested more than 32 deep is an evaluation error. A child ruleset inherits functions and
cannot redefine one (FUNCTION_REDEFINED).
Custom operators are named x-<name>, declared under operators, and implemented by the host
in every runtime that evaluates the ruleset. They are pure functions of plain JSON arguments. A
missing operator, a failure in one or a non-finite result fails the rule closed. A manifest lists
the operators its rules can reach, so a host can check at start-up that it registered them all.
Consequences
- A function is part of the contract: checked at load, covered by the checksum, identical in every runtime, shipped in the client manifest only when a client rule calls it.
- Every expression still terminates, and its cost can be read from the ruleset.
- What needs recursion or a loop over anything but a list in the request cannot be a function.
- A custom operator is code to write and keep identical once per runtime, the browser included.
It is not available in the
rule-cascadecommand, the WebAssembly module or the stock rule server, and the linter cannot run golden tests that depend on it. - The guidance is to prefer functions and to treat each custom operator as a cost.
Alternatives considered
- Custom operators only. Simple reuse would become host code in every runtime.
- Dynamic scope, where a body sees the caller's
item. Functions would depend on where they are called from, and the scope check at load would be impossible. - Recursion with a depth limit. Cost and termination would depend on the data instead of the ruleset.
- Letting a child redefine a function. It would change the meaning of inherited rules without touching them, around the override policy.
- An embedded scripting language. Code execution from a ruleset, and one more runtime to make identical everywhere.
- Growing the core operator list for each need. Every addition is a new profile for every runtime; domain-specific checks do not belong there.