Overule: Ruby Rule Engine
Overview
Overule is a lightweight rule engine for Ruby that enables definition and evaluation of business rules with nested conditions and multiple operators. It ships with an optional mountable Rails engine that provides a browser-based rule builder — useful when business users (rather than developers) need to author rules. Persistence works on both ActiveRecord and Mongoid, selected via a config setting.
Features
- Flexible rule definition with arbitrarily-nested AND/OR groups
- Comparison operators:
eq,neq,gt,lt,gte,lte,in,nin,contains,range - Datatypes:
string,select,array,number,integer,float,decimal - Automatic numeric coercion for ordering operators (
gt,lt,gte,lte,range) on numeric datatypes — so"1220000" > "2000000"no longer compares lexically - Static value assignment as the rule's action
- Optional mountable Rails engine with an Alpine.js + Tailwind UI for CRUD on rules
- Persistence via
Overule::Rule— backed by ActiveRecord (default) or Mongoid, both with the same model API - Immutable rule-body versioning (
Overule::RuleVersion) and full activity log (Overule::RuleActivity)
Installation
# Gemfile
gem "overule"
The core gem only depends on activesupport. The Rails engine code loads conditionally — if your host app doesn't have Rails, the gem still works as a plain library.
Basic Usage
# Define facts about the world
facts = {
product_status: "active",
access_technology: "vdsl",
conditional_id: "1231132",
material_id: "1221134"
}
# Define a rule: when conditions match, fire static outputs
rules = {
when: {
cond: [
{ datatype: "select", value: "active", op: "eq", var: "product_status" },
{ datatype: "array", value: ["vdsl", "ftth"], op: "in", var: "access_technology" }
],
set: [],
op: "and"
},
then: {
"$static": {
eligible: true,
tier: "premium"
}
}
}
# Evaluate
Overule::Inference.new(rules, facts).infer
# => { "eligible" => true, "tier" => "premium" }
If the when clause is false, #infer returns nil.
Rule Structure
Condition
A single check against a fact.
{ var: "<fact-name>", op: "<operator>", value: <value>, datatype: "<datatype>" }
var— name of the fact to look upop— operator (see below)value— value to compare against; forin,nin,rangethis must be an arraydatatype— hint for type coercion (see numeric coercion below)
Operator semantics
| Operator | Meaning | Value shape |
|---|---|---|
eq |
fact == value |
scalar |
neq |
fact != value |
scalar |
gt / lt / gte / lte |
numeric/lexical ordering | scalar |
contains |
fact.include?(value) (substring or array membership) |
scalar |
in |
value.include?(fact) (membership in a list) |
array |
nin |
!value.include?(fact) |
array |
range |
fact >= value.first && fact <= value.last |
array of [min, max] |
Datatypes and which operators apply
The Rails UI restricts the operator picker per datatype. The mapping is also a useful guide for hand-authored rules:
| Datatype | Operators |
|---|---|
string |
eq, neq, contains |
select |
eq, neq |
array |
contains, in, nin |
number / integer / float / decimal |
eq, neq, gt, lt, gte, lte, range |
Numeric coercion
For ordering operators (gt, lt, gte, lte, range), if datatype is one of number, integer, float, decimal, both operands are coerced to Float before comparison. This stops the classic gotcha where "1220000" > "2000000" is true by string ordering.
Rule Components
whencond— array of atomic conditions (above)set— array of nested rule-groups (each with its owncond/set/op) — recursiveop—"and"or"or"to combinecondresults andsetresults
then$static— hash of values returned whenwhenevaluates true
Classes
Overule::Inference
result = Overule::Inference.new(rule_hash, facts_hash).infer
# returns the action hash if the rule matches, nil otherwise
Overule::Context
Wraps facts as a HashWithIndifferentAccess.
ctx = Overule::Context.new(facts)
ctx.get("product_status") # => "active"
ctx.set("product_status", "inactive")
Overule::Condition
Pure-function evaluator for an array of conditions against a context.
Overule::Condition.evaluate(cond_array, ctx) # => [true, false, ...]
Overule::Operator
The operator lookup table.
Overule::Operator.operate("eq", 1, 1) # => true
Web UI (Rails engine)
The optional mountable Rails engine gives you a browser-based rule builder. It loads only when Rails is present, so plain-Ruby use is unaffected.
Setup (ActiveRecord — default)
# host Gemfile
gem "overule"
# config/routes.rb (host app)
Rails.application.routes.draw do
mount Overule::Engine, at: "/overule"
end
bin/rails generate overule:install # copies migrations + config/initializers/overule.rb
bin/rails db:migrate
bin/rails server # visit http://localhost:3000/overule
Setup (Mongoid)
# host Gemfile
gem "mongoid"
gem "overule"
# config/routes.rb — same as the AR setup
Rails.application.routes.draw do
mount Overule::Engine, at: "/overule"
end
bin/rails generate overule:install --orm=mongoid
bin/rails db:mongoid:create_indexes # create the indexes declared on the models
bin/rails server
--orm=mongoid makes the generator pre-set config.orm = :mongoid in the initializer and skip the SQL migrations. The engine's models declare equivalent indexes via index ... so db:mongoid:create_indexes is all that's needed.
Everything past this point — the route mount, the recursive builder UI, the activity log, versioning, the actor_proc hook, the JSON preview — is identical across both ORMs. The model API (Overule::Rule, RuleActivity, RuleVersion) is the same; only the storage layer differs.
Configuration
config/initializers/overule.rb is generated by overule:install:
Overule.configure do |config|
# ORM that backs persistence. Supported:
# :active_record (default) — uses bundled migrations
# :mongoid — no migrations; uses model-declared indexes
# config.orm = :active_record
# Attribute every rule change in the activity log to a user. Receives the
# current Overule controller, returns a string identifier (e.g. email).
# config.actor_proc = ->(controller) { controller.current_user&.email }
# Gate the Overule UI behind HTTP Basic auth (default: false, no gate).
# When enabled, set both username and password.
# config.http_basic_auth = true
# config.http_basic_auth_username = ENV.fetch("OVERULE_HTTP_BASIC_USERNAME")
# config.http_basic_auth_password = ENV.fetch("OVERULE_HTTP_BASIC_PASSWORD")
end
HTTP Basic auth (optional gate)
If the host app doesn't already have an authentication layer in front of /overule, three flat config settings can gate every Overule action:
config.http_basic_auth = true
config.http_basic_auth_username = ENV.fetch("OVERULE_HTTP_BASIC_USERNAME")
config.http_basic_auth_password = ENV.fetch("OVERULE_HTTP_BASIC_PASSWORD")
Behavior:
- Unauthenticated requests get
401 UnauthorizedwithWWW-Authenticate: Basic realm="Overule". - Credentials are compared with
ActiveSupport::SecurityUtils.secure_compareand bitwise&so both username and password are always evaluated — the response time can't leak which side mismatched. - When
http_basic_authisfalse(the default) the engine doesn't issue an auth challenge — it relies on whatever your host app already does. - Setting
http_basic_auth = truewhile leavinghttp_basic_auth_username/http_basic_auth_passwordnilraisesArgumentErroron the first request, so misconfiguration fails loudly.
The initializer is evaluated during Rails initialization, before the engine's models are autoloaded, so the ORM choice is locked in by the time Overule::Rule is first referenced.
What you get
Overule::Rule model — name (unique), description, definition (JSON / Hash), enabled, timestamps. Validates that definition has a when and a then. Same API under ActiveRecord and Mongoid; the file at app/models/overule/rule.rb picks its base class at load time based on Overule.config.orm. Shared validations, callbacks, and version/activity logging live in Overule::RuleBehavior (in app/models/concerns/overule/).
rule = Overule::Rule.find_by(name: "eu-customers")
rule.infer(country: "DE") # => { "tier" => "eu" } if it matches
Browser UI at the mount point with:
- A recursive AND/OR condition group builder — nest groups arbitrarily deep
- Datatype-aware operator picker (the table above)
- Array value editor with
+ Itemrows forin,nin,range - Typed
$staticoutput editor — each output entry has a datatype (string,number,boolean,null,array,object); arrays and objects can be nested - Live JSON preview with copy-to-clipboard
- Backed by
Overule::Inference— what you see in the preview is exactly what gets evaluated at runtime
All assets are loaded from CDN (Tailwind, Alpine.js) — no build step required in the host app.
Versioning and activity log
Every rule keeps an immutable history of its body (definition) as Overule::RuleVersion rows tagged with a monotonic version number per rule:
- Creation captures
v1. - Each subsequent edit to the
definitionbody capturesv2,v3, … - Metadata-only edits (
name,description,enabled) do not create a new version — they still produce an activity log entry, but the entry links to the current body version. - Deletion links the "destroyed" activity to the last version that existed.
Each version stores a full snapshot of the audited columns at the moment it was captured (name, description, enabled, definition). Snapshots are independent rows — mutating the current rule never touches prior versions.
View the version history at /overule/rules/:id/versions and a specific snapshot at /overule/rules/:id/versions/:version (read-only, with prev/next navigation). Every activity row also shows a vN badge that links straight to that version.
The activity feed lives at /overule/activities (global) and each rule's show page surfaces its recent activity inline with version badges.
To attribute changes to a user, either set config.actor_proc in the initializer (recommended — see Configuration above) or set Overule::Current.actor from your own before_action:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action { Overule::Current.actor = current_user&.email }
end
When unset, activities are stored with actor: nil and displayed as "anonymous".
Activity rows are retained when a rule is deleted: the rule_id reference is nullified but rule_name is preserved, so the audit trail survives. Same behavior under AR (via dependent: :nullify) and Mongoid.
Storage notes
ActiveRecord — the generated migration uses t.json :definition. This maps to:
- PostgreSQL →
json(use a follow-up migration to switch tojsonb+ a GIN index if you need to query inside the JSON) - MySQL → native
JSON - SQLite →
TEXTwith Rails-side JSON casting (Rails 7.1+)
Mongoid — definition is field :definition, type: Hash, stored as BSON. Queryable directly without any extra setup.
Custom Postgres schemas (AR only) — if your host app uses a custom schema_search_path (e.g., dtd2d instead of public), the generated create_table :overule_rules will be placed in the first schema on that path. To force a specific schema, edit the generated migration to use a qualified name: create_table "dtd2d.overule_rules" and add_index "dtd2d.overule_rules", :name, unique: true.
Development
bundle install
bundle exec rake # runs core + engine tests
bundle exec rake test # core (plain Ruby) tests only
bundle exec rake test_engine # Rails engine tests
The engine tests boot a minimal Rails app at test/dummy/ against an in-memory SQLite database.
Contributing
- Fork the repository
- Create your feature branch
- Commit your changes
- Push to the branch
- Create a new Pull Request
License
MIT License