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);
  • 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, boolean 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 where it can and falls back to an equality (test ...) where it cannot — a bind: you declared is never renamed.

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
Temporal changed_within, count_exceeds, rate_exceeds, last_n, distinct_count, sequence_detected, schema_frequency_exceeds

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, the hierarchy cannot be resolved at compile time and the call goes to the unscoped shim, which is the first hierarchy loaded. Keep such comparisons within one hierarchy.

Temporal operators emit (test …) CEs that call external functions registered at runtime.

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.
scope str \| None None Accepted for authoring but not emitted by _compile_action (reserved).
asserts list[AssertSpec] [] YAML key is the singular assert (mapped via populate_by_name). Each entry becomes one (assert (<template> …)) on the RHS.

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.
  • 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 whenCompilationError from compile_rule.
  • Unsupported operator in an expressionCompilationError 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 \x00ValueError 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.

See also