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¶
Emits a CLIPS slot constraint via _compile_condition. See
Supported operators.
Shape 2 — slot + bind (no expression)¶
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¶
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.
What the validator rejects¶
- Empty entry (no
expression,bind, ortest). slotset but neitherexpressionnorbindprovided.slotset alongside a standalonetestwith noexpression/bind— the slot would have no effect; drop it or add anexpression/bind.bindthat does not start with?.testthat is empty or not parenthesized.
Supported operators in expression¶
Grammar¶
An expression is exactly one operator application:
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 asalias: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 ascontains($5.00)stays literal.matchestakes a literal pattern only:matches($alias.field)is aCompilationError, because a regex read from a fact is untrusted input to a backtracking engine.inandnot_intake no cross-fact reference; - a list,
[a, b, c], forinandnot_inonly; - 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
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 whensalience == 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. reasonwith{placeholder}placeholders becomes(str-cat …); literal reasons are emitted as plain quoted strings.notifyis always quoted — empty list emits"".metadatais empty-string when{}, otherwisejson.dumps(metadata, sort_keys=True).- The
__fathom_decisionassert precedes user asserts in document order (AC-1.3). - When
actionisNonethe assert shrinks to(assert (__fathom_decision (seq ?*fathom-decision-seq*) (rule "…"))), which records the firing forrule_tracewithout rendering a decision. seqexists 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
nameor invalid CLIPS identifier —ValueError/CompilationError. - Empty
when—CompilationErrorfromcompile_rule. - Unsupported operator in an
expression—CompilationErrorfrom_compile_condition. AssertSpec.templateor slot key that is not a valid CLIPS identifier —ValueError.- Slot value with unbalanced parens, malformed
?var, or embedded\x00—ValueErrorfrom_validate_slot_value. ThenBlockwith neitheractionnor a non-emptyassertlist —ValueErrorfrom_require_action_or_asserts.ConditionEntryrejections listed under What the validator rejects.- A top-level key other than
ruleset,version,moduleandrules—CompilationErrorfromparse_rule_file/reload_rules. - A pattern whose
templateis not registered —CompilationErrorfromEngine.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 validatereports the same error against the templates it can see: those under the validated path, a rule file's siblingtemplates/, and the templates of an installed pack'sPACK_DEPENDENCIES.