Skip to content

Loading a rule pack

Fathom supports two ways to ship rules into an Engine: as a directory tree you maintain alongside your application, or as an installable Python package discovered at runtime through a setuptools entry point. Both routes funnel through the same loaders, so the YAML you author looks identical either way — only the delivery mechanism differs.

Load from a directory

Engine.from_rules(path, **kwargs) is a classmethod that returns a configured Engine. Any keyword arguments are forwarded to the Engine(...) constructor.

from fathom.engine import Engine

engine = Engine.from_rules("examples/01-hello-allow-deny")
result = engine.evaluate()

The loader tries two discovery strategies, in order:

  1. Subdirectory convention (preferred). If the pack directory contains any of templates/, modules/, functions/, or rules/, each present subdirectory is passed to the matching engine.load_templates, load_modules, load_functions, or load_rules method.
  2. Key-inspection fallback. If none of those subdirectories exist, every *.yaml file directly under path is opened and routed by its top-level key: templates → templates loader, modules or focus_order → modules loader, functions → functions loader, rules or ruleset → rules loader.

Both strategies load in the same fixed order: templates → modules → functions → rules. The order matters because templates define the slot schemas that rules bind against, modules establish the namespaces that rules live in, and functions may be referenced from a rule's left-hand side via the raw-CLIPS test: clause — so everything a rule depends on must be compiled before the rule itself.

Directory layout

For anything larger than a toy example, use the subdirectory convention. The reference shape is what examples/01-hello-allow-deny ships with:

my-pack/
├── templates/
│   └── *.yaml
├── modules/
│   └── *.yaml
├── functions/
│   └── *.yaml
└── rules/
    └── *.yaml

Every YAML file under a given subdirectory is loaded. The glob is *.yaml only — files ending in .yml are not picked up by from_rules, so stick to the long extension. Empty or missing subdirectories are fine; the loader simply skips them.

The key-inspection fallback is handy for tiny single-file packs where splitting into subdirectories would be overkill, but it's strictly less expressive: it only scans the top level of path (no recursion) and each file must declare exactly one top-level key that the loader recognises.

Load a directory into an engine you already have

from_rules is a constructor: it hands you a new Engine. When the engine already exists — a long-lived process, a pack uploaded at runtime, a second pack added to the first — use Engine.load_pack_dir(path):

from fathom.engine import Engine

engine = Engine(audit_sink=my_sink)
engine.load_pack_dir("/srv/packs/kssi/v1")
engine.load_pack_dir("/srv/packs/tenant-overrides")

It accepts both layouts above, loads in the same fixed order, and is the same implementation from_rules and load_pack use — so there is one place that knows the order rather than one per caller.

Three things it does that hand-rolling the four load_* calls does not:

  • Loading the same directory twice is a no-op. Identity is the resolved path, so a relative and an absolute spelling are one pack, not two.
  • A second pack may not redefine a template the first registered. The check runs before anything is built, so a rejected pack leaves the engine untouched. This is the same check load_pack applies.
  • A directory holding nothing it recognises is an error, not an engine with no rules in it. Pointing one level too high used to succeed silently.

What it does not do is resolve PACK_DEPENDENCIES: a directory has no module to declare them on, so a pack that needs another one loaded first must have that one loaded by its caller. from_rules keeps its older, laxer behaviour on an empty directory — it returns an empty engine rather than raising, because FleetEngine builds session engines that way.

Load a distributed pack via entry point

Once a pack is packaged as a Python distribution, load it by name:

from fathom.engine import Engine

engine = Engine()
engine.load_pack("owasp-agentic")

Engine.load_pack delegates to RulePackLoader, which walks the fathom.packs entry-point group, imports the registered module, resolves its on-disk location via module.__path__, and then runs the same templates/modules/functions/rules/ subdirectory load as from_rules does.

To expose your own pack, add an entry to your pyproject.toml:

[project.entry-points."fathom.packs"]
my-pack = "my_package.rules"

Here my_package/rules/ is an importable package directory that contains the familiar templates/, modules/, functions/, and rules/ subdirectories full of YAML. After pip install, any process with Fathom installed can call engine.load_pack("my-pack").

Packs shipped with Fathom

Fathom currently ships seven first-party rule packs, registered under the same entry-point group in its own pyproject.toml:

  • owasp-agentic
  • ssvc
  • nist-800-53
  • hipaa
  • cmmc
  • schema-denoising
  • conflict-detection

The first five render decisions. The last two do not: their rules only assert facts, and the host reads those facts or writes its own rules over them.

  • schema-denoising filters an upstream extraction stream down to the relation types that appear often enough to trust, leaving aligned_fact facts behind.
  • conflict-detection reports contradictions between claims as conflict facts. It does not resolve them: what to do about a contradiction is a policy question, so it belongs in your rules rather than in the pack.

Pack dependencies

A pack module may declare PACK_DEPENDENCIES, a tuple of pack names to load first:

# my_package/rules/__init__.py
PACK_DEPENDENCIES = ("nist-800-53",)

load_pack resolves those before loading the pack itself. cmmc uses this: it builds on nist-800-53, so engine.load_pack("cmmc") loads both.

Loading the same pack twice is a no-op

load_pack tracks which packs an Engine already holds and skips a repeat load rather than re-running the loaders (which CLIPS would reject with [CSTRCPSR4] Cannot redefine deftemplate … while it is in use). This is what makes shared dependencies safe: loading nist-800-53 and then cmmc does not load NIST twice.

The tracking is per-Engine, and Engine.reload_rules() clears it (engine.py calls packs.forget_packs). A reload swaps in a fresh CLIPS environment and discards the rule registry, so a previously-loaded pack's rules are gone; keeping the record across that made the obvious repair — load the pack again — a silent no-op that returned successfully while the rules stayed absent. Clearing it means the retry is attempted, and fails loudly if it cannot succeed. The same applies to load_pack_dir, which shares the record.

Packs that define the same template

Two packs may not define the same template name with different definitions in one Engine. load_pack checks for this before loading anything and raises CompilationError:

Rule pack 'nist-800-53' redefines template 'data_transfer' already
registered by rule pack 'hipaa'

The check runs first, so the second pack is not half-loaded: the engine is left exactly as it was. Identical definitions are allowed and simply reused.

This affects real combinations today — hipaa and nist-800-53/cmmc both define data_transfer, audit_event and access_request with different slots, so they cannot share an Engine. Load them into separate Engine instances and evaluate against each.

Error handling

If you pass a name that isn't registered under fathom.packs, RulePackLoader.discover raises CompilationError with construct="pack:<name>". The same error is raised if the registered module has no resolvable path (neither __path__ nor __file__), which in practice only happens for exotic namespace-package setups.

When to use which

  • Engine.from_rules(path) — your application owns the rules, they live in a directory inside the repo (or a mounted volume), and you want the fastest edit-reload loop. Best for development, single-tenant deployments, and environment-specific overrides.
  • engine.load_pack(name) — the rules are a redistributable asset consumed by multiple applications, need independent versioning, and can be published alongside your Python wheels. Enables pip install compliance-pack style workflows and clean upgrades.

Nothing stops you from mixing both in one Engine: call load_pack for a shared baseline, then load_rules or load_templates on a local directory for application-specific overlays.

  • Python SDK reference — full Engine API, including every load_* method used here.
  • Writing rules — YAML authoring conventions for the files inside a pack.
  • YAML schema reference — the top-level keys (templates, modules, functions, rules) that the key-inspection fallback looks for.