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); - 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, 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
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 whensalience == 0.- Test CEs appear after all pattern CEs, in source order across patterns.
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.