Exporting rules as Rego¶
fathom convert to-rego writes the stateless subset of a Fathom ruleset out as
Rego — for
handing a policy to a team that runs OPA, diffing it against an existing Rego
policy, or shipping one decision into an OPA deployment you do not control.
Rego is the smaller language here, so expect refusals. Most of what makes a Fathom ruleset worth writing — facts that persist across evaluations, joins across facts, the temporal and classification operators — has no Rego form at all. Those rules are reported, not flattened into Rego that parses and means something narrower.
Export a ruleset¶
fathom convert to-rego ./my-ruleset
fathom convert to-rego ./my-ruleset -o policy.rego --package authz.basic
Without --package, the package name is the module the rules declare.
# Generated by `fathom convert to-rego`.
#
# The stateless subset only: rules that match one fact against literals.
# Anything the exporter refused was reported on stderr, not written here.
package authz.basic
import rego.v1
default allow := false
default deny := false
# authz.basic.allow (converted from Rego)
# fathom rule: allow-1
allow if {
input.user_role == "admin"
input.action != "delete"
}
The rule's then.reason becomes a comment — every line of it. Rego's
answer is a bare boolean with nowhere to put a reason, and dropping it loses
the one thing that explains the rule. Only the first line used to be
prefixed, so a two-line reason was written into the exported policy as live
Rego: opa rejected the file, or, when the second line happened to parse,
accepted a rule the Fathom ruleset never had.
A condition comparing a slot against its own absent-sentinel is not exported.
It is the encoding fathom convert rego uses to stand in for "the field is
present at all", and Rego says that natively.
What exports¶
A rule exports when it matches one fact against literals and produces a
decision. The action becomes the Rego document name, so a Fathom allow rule
becomes allow if { ... } with a default allow := false beside it.
| Fathom condition | Rego |
|---|---|
equals(v) / not_equals(v) |
== "v" / != "v" |
greater_than(n) / less_than(n) |
> n / < n |
in([a, b]) |
in {"a", "b"} |
not_in([a, b]) |
not ... in {"a", "b"} |
contains(v) |
contains(input.x, "v") |
matches(p) |
regex.match("p", input.x) |
Literals are rendered from the declared slot type, not from how the text
looks: equals(5) on a string slot is the string "5", and on an integer
slot it is 5. A symbol slot holding true or false becomes a Rego
boolean, which is the inverse of what
the importer does on the way in.
A Fathom argument that carries its own quotes — equals("Paris, France") —
is unquoted before it is re-encoded, so the Rego string holds the value and
not a pair of quote marks. Set members are split on top-level commas only,
so a member holding one stays a single member.
How slots are addressed¶
Rego has one input document, and Fathom facts are typed. So the template
name stays in the path: a rule over template request reads
input.request.role. Two rules over different templates can never match the
same fact, and collapsing them onto one document root would say they can.
The exception is a ruleset whose only template is named input — the shape
fathom convert rego produces. There the slots sit at the document root
(input.user_role), so a policy imported from Rego exports back to the shape
it started in.
What it refuses¶
Each refusal names the rule and the reason, on stderr. Refusals that share a reason are grouped, so a ruleset built on cross-fact joins reports that once with a count rather than once per rule.
| Rule | Why not |
|---|---|
| More than one fact pattern | The rule joins across facts; Rego has one input document to join against. |
then.assert |
The rule adds to working memory. Rego derives documents from one input and cannot. |
No then.action |
Pure inference — the part of Fathom Rego does not have. |
then.scope |
The decision carries a scope value; a Rego document is a boolean here. |
| A temporal operator | changed_within, count_exceeds and the rest ask about history, and Rego has no memory of the last input. |
| A classification operator | below, meets_or_exceeds and the rest resolve against a hierarchy, which is engine state. |
bind: or test: |
A bind is a join; a raw CLIPS conditional element has no translation. |
A $alias.field reference |
A cross-fact reference. |
A refused rule is left out entirely. --strict exits non-zero when anything
was refused; without it the command exits non-zero only when nothing
exported.
What the export changes even when it succeeds¶
Two differences are reported as notes rather than refusals, because the rules themselves convert cleanly:
- Precedence. Fathom picks one decision per evaluation. Rego evaluates
allowanddenyindependently, so a policy defining both leaves the precedence to the caller — conventionally, deny wins. - Salience. Rego has no rule ordering. A ruleset that relies on one rule firing before another does not mean the same thing once exported.
Round-tripping¶
A Rego policy taken through fathom convert rego and back
out through to-rego lands on the same rules. The package name does not
survive on its own — Fathom module names cannot hold dots, so authz.basic
becomes the module authz_basic — so pass --package authz.basic to restore
it.