Overule: Ruby Rule Engine

What is a 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 up
  • op — operator (see below)
  • value — value to compare against; for in, nin, range this must be an array
  • datatype — 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

  • when
    • cond — array of atomic conditions (above)
    • set — array of nested rule-groups (each with its own cond/set/op) — recursive
    • op"and" or "or" to combine cond results and set results
  • then
    • $static — hash of values returned when when evaluates 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 Unauthorized with WWW-Authenticate: Basic realm="Overule".
  • Credentials are compared with ActiveSupport::SecurityUtils.secure_compare and bitwise & so both username and password are always evaluated — the response time can't leak which side mismatched.
  • When http_basic_auth is false (the default) the engine doesn't issue an auth challenge — it relies on whatever your host app already does.
  • Setting http_basic_auth = true while leaving http_basic_auth_username / http_basic_auth_password nil raises ArgumentError on 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 modelname (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 + Item rows for in, nin, range
  • Typed $static output 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 definition body captures v2, 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 to jsonb + a GIN index if you need to query inside the JSON)
  • MySQL → native JSON
  • SQLite → TEXT with Rails-side JSON casting (Rails 7.1+)

Mongoiddefinition 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

  1. Fork the repository
  2. Create your feature branch
  3. Commit your changes
  4. Push to the branch
  5. Create a new Pull Request

License

MIT License