Skip to content

Rule

A rule pairs fact-pattern conditions (the when clause) with a decision and optional fact assertions (the then clause). Rules compile to CLIPS defrule constructs scoped to their enclosing module. For conceptual context see Five Primitives; for how salience and last-write-wins interact at evaluation time see Runtime & Working Memory.

Top-level fields — RuleDefinition

Field Type Default Required Description
name str — yes CLIPS identifier. Must match ^[A-Za-z_][A-Za-z0-9_\-]*$. Emitted as (defrule <module>::<name> …).
description str "" no Author-facing prose. Not emitted to CLIPS.
salience int 0 no Priority hint. Emitted as (declare (salience N)) only when != 0.
when list[FactPattern] — yes LHS fact patterns. Pydantic accepts an empty list; compile_rule raises CompilationError when empty.
then ThenBlock — yes RHS decision and/or asserts. See ThenBlock below.

name is validated by _name_must_be_clips_ident. compile_rule in src/fathom/compiler.py re-checks emptiness and the when-list.

Fact-pattern fields — FactPattern

Field Type Default Required Description
template str — yes The template name this pattern matches. Emitted as the head of the pattern CE: (<template> …).
alias str \| None None no Optional name used as the cross-fact prefix in other patterns' expressions ($alias.slot). Resolved via _resolve_cross_refs.
conditions list[ConditionEntry] — yes Slot constraints and/or test CEs. An empty list emits a bare (<template>) pattern.

Condition entry — ConditionEntry

A ConditionEntry supports four shapes. The enforcing validator is _require_bind_or_expression in src/fathom/models.py.

Shape 1 — slot + expression

- slot: role
  expression: equals(admin)

Emits a CLIPS slot constraint via _compile_condition. See Supported operators.

Shape 2 — slot + bind (no expression)

- slot: subject_id
  bind: ?sid

bind must start with ? (enforced by _bind_must_start_with_question_mark). Emits (<slot> ?sid) — captures the slot's value so peer conditions and the RHS can refer to it.

Shape 3 — standalone test

- test: (my-fn ?sid)

Only test is set. test must be a parenthesized CLIPS expression (enforced by _test_must_be_wrapped). Emits (test <expr>) on the rule LHS after all pattern CEs — the escape hatch for custom functions registered via Engine.register_function.

Shape 4 — combinations

bind + expression constrains and captures in the same slot; test combined with a slot/expression appends a (test …) CE after the enclosing pattern.

- slot: amount
  bind: ?amt
  expression: greater_than(100)
  test: (policy-allows ?amt)

What the validator rejects

  • Empty entry (no expression, bind, or test).
  • slot set but neither expression nor bind provided.
  • slot set alongside a standalone test with no expression/bind — the slot would have no effect; drop it or add an expression/bind.
  • bind that does not start with ?.
  • test that is empty or not parenthesized.

Supported operators in expression

Grammar

An expression is exactly one operator application:

expression := operator "(" argument ")"
operator   := [A-Za-z_][A-Za-z0-9_]*

Lexing. The operator is everything before the first (; the argument is everything between that ( and the final ) of the string. The expression must end with ).

Balance. For every operator except contains and matches, the parentheses in the argument must balance and may close only on that final character. This is what stops an argument from closing the pattern early and smuggling extra conditional elements into the generated defrule (equals(a1)) (admin (level 9) is rejected). Text inside a double-quoted CLIPS string does not count toward the balance, and \ escapes the next character.

contains and matches are exempt: the compiler emits their argument as an escaped, quoted CLIPS string ((str-index "…" ?v) and (fathom-matches ?v "…")), so it cannot break out whatever it holds. Bare parentheses are therefore legal in those two — matches([)]) and contains(a :-) b) are valid — which matters because ( and ) are literal inside a regex character class.

Argument forms. An argument is one of:

  • a cross-fact reference, $alias.field, resolved to the CLIPS variable ?alias-field. The alias must be declared as alias: on another pattern in the same rule (see Aliases below); contains($alias.field) joins too, and only on that exact form (an identifier, a dot, an identifier), so free text such as contains($5.00) stays literal. matches takes a literal pattern only: matches($alias.field) is a CompilationError, because a regex read from a fact is untrusted input to a backtracking engine. in and not_in take no cross-fact reference;
  • a list, [a, b, c], for in and not_in only;
  • a literal, everything else.

How a literal is emitted depends on the declared type of the slot it is compared against, which is why compiling a rule file in isolation can differ from compiling it as part of a ruleset:

Slot type Emitted as Example
string quoted CLIPS string, escaped equals(a@b.com) → (id "a@b.com")
symbol, integer, float bare CLIPS token equals(admin) → (role admin)

Compiling without the templates is not a mistake CLIPS reliably catches. equals and not_in emit a pattern constraint, so an unquoted literal against a string slot is rejected at load time ([CSTRNCHK1]) — but not_equals and in emit a :(...) predicate, which CLIPS does not type-check, and a symbol never equals a string: the rule builds clean and then decides every fact the wrong way. Always compile against the templates — fathom compile on a ruleset directory, or on a rule file whose sibling templates/ directory is present, does this for you.

Aliases. alias: on a fact pattern names it for $alias.field references. An alias must start with $ followed by a CLIPS identifier, may not be $p<number> (that namespace is generated for unaliased patterns), and may not be reused by two patterns in the same rule. Referencing an alias no pattern declares is a compile error — CLIPS would otherwise accept the rule and match everything, since the reference would be the variable's first occurrence and so bind rather than constrain.

Compiling a reference makes the aliased pattern mention the join variable too, adding a slot constraint if the pattern had none on that slot:

when:
  - template: access_request
    alias: $req
    conditions:
      - slot: mode
        expression: equals(read)
  - template: resource
    conditions:
      - slot: id
        expression: equals($req.object_id)   # joins on ?req-object_id
(access_request (mode "read") (object_id ?req-object_id))
(resource (id ?req-object_id))

Only one variable may bind a slot, so when the aliased slot already binds one the compiler reuses it: a generated variable is renamed to the join variable, and a bind: you declared (or another alias's join variable) is kept and every reference is rewritten onto it. A bind: you declared is never renamed, and the join works for every operator that takes $alias.field.

Stability. The operator set and this grammar are covered by the 1.0 compatibility promise. The generated CLIPS variable names (?s_<index>_<slot>, ?<alias>-<slot>) are an implementation detail and may change in a minor release — do not depend on them from a test: CE; use bind: to name a variable you need.

Source: _compile_condition in src/fathom/compiler.py and _validate_expression in src/fathom/models.py.

Group Operator
Comparison equals, not_equals, greater_than, less_than
Set in, not_in
String contains, matches
Classification below, meets_or_exceeds, within_scope
Compartment in_compartment, has_compartments
Temporal changed_within, count_exceeds, rate_exceeds, last_n, distinct_count, sequence_detected, schema_frequency_exceeds

matches takes a Python re pattern. A pattern that does not compile is a CompilationError when the rule loads.

A runtime error fails the evaluation closed. If a condition or an action errors while a request is evaluated -- a registered function that raises, a value over the 4096-character matches cap, a division by zero in a then.assert slot -- the evaluation raises EvaluationError and returns no decision. It does not return the decision written before the error: the rule that errored may have been the deny meant to override it. CLIPS's own printout of the error, which for a pattern-matching error holds the whole fact being matched, is not written to the process's stderr: the slot values may be sensitive.

Classification operators require a classification function declared with a hierarchy_ref elsewhere in the YAML bundle — the operator emits a call to the generated below / meets-or-exceeds / within-scope CLIPS deffunction.

Which hierarchy is decided by the level you name. meets_or_exceeds(verified) compiles to trust-meets-or-exceeds because verified is a level of the trust hierarchy and of no other one loaded. There is no syntax for naming a hierarchy, and none is needed: the level is the discriminator. Two consequences, both compile errors rather than a silently wrong answer:

  • a level no loaded hierarchy defines — a typo, or a hierarchy the bundle forgot — fails to compile, naming the level and every loaded ladder;
  • a level two hierarchies both define is ambiguous and fails to compile. Rename it in one of them.

When the argument is a cross-fact reference ($other.level) rather than a literal, there is no level to resolve by. With one hierarchy loaded the call goes to that hierarchy; with more than one it is a CompilationError. Compare with a literal, or call <hier>-meets-or-exceeds from a test: condition.

Compartment operators read the slot as a |-delimited compartment set ("nato|five"; surrounding spaces and empty members are ignored) and need no hierarchy. in_compartment(X) is true when compartment X is in the slot's set; has_compartments(X) is true when every compartment in X is, so has_compartments("") is always true. The argument is a single token, a quoted string (quote it when it holds a |: has_compartments("nato|five")), or $alias.field. They compile to fathom-has-compartment and fathom-compartments-superset. There is no dominates operator; call (fathom-dominates level-a comps-a level-b comps-b hierarchy) from a test: condition.

An unknown level stops the evaluation. A value that is not a level of the hierarchy -- a typo, a different case, a label from another ladder -- on either side of below, meets_or_exceeds or within_scope raises, and the evaluation fails with EvaluationError instead of returning a decision. It is never ranked below or above anything, and it is not read as false either: a deny written with below would then skip the typo label. The REST, gRPC and MCP surfaces return their evaluation-error response (evaluation failed).

Temporal operators emit (test …) CEs that call external functions registered at runtime. A rule that uses one also matches an internal (__fathom_epoch) fact, placed after its own patterns. Engine.evaluate (and so evaluate_once) replaces that fact before every run, so the operator is judged over the whole fact set at run time: the decision does not depend on the order the facts were asserted in. Facts a rule asserts during the run are not seen by a test that has already run. Engine.step does not replace the epoch fact (that would re-fire every temporal rule each step), so in a stream a temporal test runs when the pattern it sits on first matches.

Temporal operator signatures. template and the slot arguments are bare names; value is a literal or a $alias.field reference to a slot of the condition's own pattern; numeric arguments must be finite numbers.

Operator Arguments True when
changed_within (window) the condition's slot (a timestamp) is less than window seconds old
count_exceeds (template, slot, value, threshold) more than threshold template facts have slot = value
rate_exceeds (template, slot, value, threshold, window[, ts_slot]) as count_exceeds, counting only facts whose ts_slot (default ts) is within window seconds
last_n (template, slot, value, n) at least n template facts have slot = value
schema_frequency_exceeds (template, slot, value, tau) same predicate as last_n
distinct_count (template, group_slot, count_slot, threshold) some group_slot value has more than threshold distinct count_slot values
sequence_detected (events, window) the events occurred in order, the first less than window seconds ago. events is a JSON list of {"template", "slot", "value"[, "slot_ts"]} objects (slot_ts defaults to ts)

All of these are checked when the rule loads, and by fathom validate, so a mistake is a CompilationError rather than an evaluation that raises: the argument count is exact (rate_exceeds takes 5 or 6), a numeric argument that is not a finite number is refused, the events list must parse, and every template and slot an operator names must be registered.

Any other operator raises CompilationError from _compile_condition. For worked examples of each operator see Writing rules.

ThenBlock fields

Field Type Default Description
action ActionType \| None None One of allow, deny, escalate, scope, route. Emitted as an unquoted symbol on the __fathom_decision fact; None leaves the slot at its none default, which never wins a decision.
reason str "" Free text. {placeholder} refs compile via _compile_reason to (str-cat "…" ?placeholder "…"); otherwise a quoted literal.
log LogLevel LogLevel.SUMMARY One of none, summary, full. Emitted as the log-level slot on the decision fact.
notify list[str] [] Notification targets. Joined with ", " and emitted as a single quoted string in the notify slot.
attestation bool False Emitted as TRUE/FALSE on the decision fact's attestation slot. Not a signing switch — see Audit & Attestation.
metadata dict[str, str] {} JSON-serialized (sorted keys) and emitted as a quoted string when non-empty; otherwise an empty quoted string.
asserts list[AssertSpec] [] YAML key is the singular assert (mapped via populate_by_name). Each entry becomes one (assert (<template> …)) on the RHS. Targeting a scope: fleet template is a CompilationError at load: a rule's assert never reaches the fleet store. So is a template, or a slot, that is not registered; fathom validate reports both.

ThenBlock validator

_require_action_or_asserts enforces that at least one of action or a non-empty assert list is provided. Rules may assert-only, decide-only, or do both. An assert-only rule still emits a __fathom_decision with action left at its none default: that fact is what rule_trace and the audit record are read from, so the rule's firing stays visible without becoming a candidate for the decision.

AssertSpec fields

Field Type Default Description
template str — Must match ^[A-Za-z_][A-Za-z0-9_\-]*$.
slots dict[str, str] {} Keys must be valid CLIPS identifiers. Values pass through _validate_slot_value: ?var refs must be well-formed, s-expressions must have balanced parens, and embedded NULs are rejected.

Enums

ActionType

YAML value Meaning
allow Permit the operation.
deny Refuse the operation.
escalate Forward for human/higher review.
scope Narrow the action's scope.
route Direct to a different handler/path.

LogLevel

YAML value Emitted audit verbosity
none No audit entry.
summary Decision + rule id.
full Decision + all facts.

Salience convention

Fathom's fail-closed default is deny rules at lower salience than allow rules, so deny fires last and wins under last-write-wins on the decision fact. Mechanics in Runtime & Working Memory.

CLIPS emission

compile_rule composes the rule in this fixed order: header, (declare (salience N)) (only when non-zero), pattern CEs in when order, test CEs collected from every pattern, the => arrow, the ?*fathom-decision-seq* increment, the __fathom_decision assert, then user asserts in declared order. Indentation is four spaces.

YAML input

- name: deny_large_transfer
  salience: -10
  when:
    - template: transfer
      alias: $t
      conditions:
        - slot: amount
          bind: ?amt
          expression: greater_than(100)
        - slot: currency
          bind: ?ccy
        - test: (blocked-country ?amt)
  then:
    action: deny
    reason: "Transfer of {amt} {ccy} exceeds limit"
    notify: [compliance, ops]
    attestation: true
    assert:
      - template: audit_log
        slots:
          subject: "?amt"

CLIPS output

(defrule finance::deny_large_transfer
    (declare (salience -10))
    (transfer (amount ?amt&?s_0_amount&:(> ?s_0_amount 100)) (currency ?ccy))
    (test (blocked-country ?amt))
    =>
    (bind ?*fathom-decision-seq* (+ ?*fathom-decision-seq* 1))
    (assert (__fathom_decision
        (seq ?*fathom-decision-seq*)
        (action deny)
        (reason (str-cat "Transfer of " ?amt " " ?ccy " exceeds limit"))
        (rule "finance::deny_large_transfer")
        (log-level summary)
        (notify "compliance, ops")
        (attestation TRUE)
        (metadata "")))
    (assert (audit_log (subject ?amt)))
)

Notes on the shape:

  • (declare (salience N)) is omitted when salience == 0.
  • Test CEs appear after all pattern CEs, in source order across patterns. A rule with a temporal operator gets (__fathom_epoch) as its last pattern CE, just before the test CEs.
  • reason with {placeholder} placeholders becomes (str-cat …); literal reasons are emitted as plain quoted strings.
  • notify is always quoted — empty list emits "".
  • metadata is empty-string when {}, otherwise json.dumps(metadata, sort_keys=True).
  • The __fathom_decision assert precedes user asserts in document order (AC-1.3).
  • When action is None the assert shrinks to (assert (__fathom_decision (seq ?*fathom-decision-seq*) (rule "…"))), which records the firing for rule_trace without rendering a decision.
  • seq exists to defeat CLIPS duplicate-fact suppression, so a rule that fires twice is traced twice instead of collapsing into one fact.

Validators — what is rejected

Model- or compile-time errors you will hit:

  • Empty name or invalid CLIPS identifier — ValueError / CompilationError.
  • Empty when — CompilationError from compile_rule.
  • Unsupported operator in an expression — CompilationError from _compile_condition.
  • AssertSpec.template or slot key that is not a valid CLIPS identifier — ValueError.
  • Slot value with unbalanced parens, malformed ?var, or embedded \x00 — ValueError from _validate_slot_value.
  • ThenBlock with neither action nor a non-empty assert list — ValueError from _require_action_or_asserts.
  • ConditionEntry rejections listed under What the validator rejects.
  • A top-level key other than ruleset, version, module and rules — CompilationError from parse_rule_file / reload_rules.
  • A pattern whose template is not registered — CompilationError from Engine.load_rules / reload_rules, before the rule is built, whether or not the pattern has slot conditions. CLIPS would otherwise create an implied deftemplate and the rule could never match. fathom validate reports the same error against the templates it can see: those under the validated path, a rule file's sibling templates/, and the templates of an installed pack's PACK_DEPENDENCIES.

See also