Emmanuel Okeke

[0%] 5/7

← cd /blogEngineering

I Shipped a Rule Engine. Then I Found Its Identical Twin.

$ cat --info "rule-engine-identical-twin.mdx"

> published: 2026-05-17 | read_time: 5 min read | category: engineering


The bug with no error

A TinyOps rule fires when a condition is met. One of the most natural conditions to write is a file-pattern match — "this pull request changes a file under src/auth/**," or "none of the changed files match *.test.*." That last one powers a common rule: comment on any PR that ships without tests.

It didn't work. Not "threw an error" didn't work — silently didn't work. The rule triggered. The execution ran. It got marked complete. And the comment never appeared. No error, no failed job, nothing in the logs to grep for. The rule just quietly did nothing, every time.

That's the worst kind of bug in a guardrail product: the guardrail isn't there, and nothing tells you.

Two engines

The cause took a while to find, because the answer was structural.

TinyOps had a rule engine: a clean, dependency-injected service — ExecutorService, ConditionService — with its own tests, its own evaluation logic, the whole thing. I'd built it carefully.

It wasn't running.

The code that actually executed rules in production was a separate process — a standalone worker that carried its own, separate copy of condition evaluation. Its own compare() function. Its own operator logic. The two implementations had drifted.

I had two rule engines and only knew about one of them.

The drift

Here's the production worker's condition comparison — the part that decides whether a rule's condition is met:

function compare(actual: unknown, operator: string, expected: unknown): boolean {
  // ...numeric operators handled above: gt, lt, gte, lte...
  switch (operator) {
    case 'eq':           return String(actual) === String(expected);
    case 'contains':     return String(actual).includes(String(expected));
    case 'not_contains': return !String(actual).includes(String(expected));
    case 'is_empty':     return actual == null || String(actual).length === 0;
    case 'is_not_empty': return actual != null && String(actual).length > 0;
    default:             return false;
  }
}

TinyOps supports twelve operators. This function handles nine. The three missing ones — matches, none_match, any_match — are the glob operators, the ones that do file-pattern matching. When they were added to the product, they were added to the other engine, the clean one. Nobody ported them here.

And the failure mode is the quiet one: an unknown operator falls through to default: return false. A rule with a glob condition doesn't error — its condition just evaluates to "not met," every single time. The engine concludes there's nothing to do and marks the run skipped. Which is exactly what I was seeing.

The fix that wasn't

The obvious fix is to add the three missing cases. I did that first. It works.

But it doesn't fix anything — it patches one symptom of a structural problem. The two engines still exist. The next operator, the next logic change, drifts again.

So I went to write a regression test. My first instinct was to copy compare() into the test file and assert against the copy.

Then I looked at what I was doing. The bug was caused by two copies of evaluation logic drifting apart. My fix was to guard against that by — adding a third copy. A test that passes while production is broken is worse than no test. It's confidence pointed at the wrong thing.

One engine

The real fix was to make the duplication impossible.

I pulled the evaluation logic — compare, the condition evaluator, the helpers — into a single side-effect-free module. The production worker imports it. The regression test imports the same module, so it exercises the real code. And the clean ConditionService — the other engine — now imports it too, instead of carrying its own copy.

// worker-main.ts        →  import { evaluateCondition } from './worker-conditions'
// condition.service.ts  →  import { compare, getNestedValue } from '../../worker-conditions'

There is now one condition evaluator. Not "two engines kept in sync" — one. Drift isn't mitigated, it's impossible, because there's nothing left to drift from. (The same change also gave the rule queue real retries — it had been dropping failed jobs with no second attempt — but the duplication was the bug that mattered.)

What I'd do differently

The honest lesson isn't "write more tests." It's that the second engine should never have existed.

It got written because the worker needed something that ran without the whole dependency-injection framework booting around it — a reasonable-sounding constraint that quietly licensed an entire parallel implementation. That's how most duplication gets in: not as a decision, but as a constraint nobody pushed back on.

If I'd extracted the evaluation logic into a plain, importable module on day one — no framework, no container, just functions — there would have been one obvious place for both callers to reach. Copying wouldn't have been tempting. It would have been extra work.

The bug was three missing case statements. I fixed those in an hour. The cause was an architecture that made copying easier than sharing — and fixing that is what actually mattered.


← Previous

Adding an AI Chatbot to My Portfolio

5 of 7

Next →

Shadow Mode: A Production Dry-Run for Rules