ADR-006: zen-engine Rules Engine
On this page
Context
Child welfare systems require configurable business rules for:
-
Safety assessments (structured decision-making tools)
-
Intake screening decisions
-
Title IV-E eligibility determination
-
Placement matching criteria
-
Timeliness compliance (CFSR/NCANDS deadlines)
Rules vary by jurisdiction (state/county) and change frequently based on policy updates. We needed a rules engine that supports non-developer rule authoring and version-controlled rule definitions.
Decision
Use zen-engine (zen-engine Rust crate, v0.54) with the JSON Decision Model (JDM) format.
Architecture
-
Dedicated eval thread: zen-engine’s
Decision::evaluate()usesRcinternally and is notSend. Evaluation runs on a dedicated single-threaded tokio runtime, communicating with async handlers viampscchannel. -
In-memory cache: Compiled rule sets are held in a
HashMap<String, Arc<ZenDecision>>behind aRwLock. Loaded from database on startup, refreshed viarules.cache_invalidatedRabbitMQ event. -
Jurisdiction-scoped names: Rule sets are prefixed with jurisdiction (e.g.,
georgia-safety-assessment,texas-ive-eligibility). TheCRAIG_RULES__JURISDICTIONenv var controls the prefix. -
Audit trail: Every evaluation attempt that resolves a rule set is recorded in
rule_evaluations, disposition-typed (#1048):completedrows carry the output;runtime_errorrows record zen failures and worker loss with a PII-freeerror_detail;timeoutrows record the #784 budget anddispatchedflag; and a timed-out evaluation that later finishes on the eval thread converges its row tolate_completedwith the output filled in (best-effort — the narrow loss windows are WARN’d and the row honestly staystimeout; see the amendment). An unknown rule set 404s unrecorded — there is no identity to audit against (therule_set_idFK is deliberate). Compile errors are unrepresentable at evaluation time (the cache holds only compiled decisions); that class pins at the rule-set write boundary — EXCEPT the #1556 pinned-replay path, which compiles a SNAPSHOT’s content per call: there an absent snapshot or no-longer-compiling content is a typed refusal BEFORE any evaluation id is minted (unrecorded, matching the unknown-rule-set precedent; the snapshot’s recorded engine_version makes the vintage auditable). See §Amendment #1048 for the write-path semantics.
Rationale
-
Rust-native: zen-engine is a Rust library, avoiding FFI overhead or a separate rules server process.
-
JDM format: JSON-based decision models can be authored in GoRules' visual editor or any JSON tool. No proprietary DSL — just JSON decision tables and expression nodes.
-
Versioned and auditable: Rule sets are stored in the database with version tracking. Imports auto-increment versions. Every completed evaluation is recorded for audit and compliance (errored/timed-out dispatches are not — see the Architecture bullet above).
-
Cross-service evaluation: Other services call
POST /v1/rules/evaluatewith context — the rules engine evaluates and returns results. Event-driven evaluation is also supported via RabbitMQ. -
Jurisdiction configurability: Different states can deploy different rule sets without code changes. The jurisdiction prefix ensures rule sets don’t collide.
JDM Structure
A JDM file contains:
-
Nodes: Input, output, decision tables, expression nodes, function nodes
-
Edges: Connections between nodes defining evaluation flow
-
Decision tables: Row-based rules with conditions and outputs
{
"nodes": [
{"id": "1", "type": "inputNode", "name": "Input"},
{"id": "2", "type": "decisionTableNode", "name": "Safety Decision", "content": {...}},
{"id": "3", "type": "outputNode", "name": "Output"}
],
"edges": [
{"sourceId": "1", "targetId": "2"},
{"sourceId": "2", "targetId": "3"}
]
}
Consequences
-
Single-threaded evaluation: The
!Sendconstraint of zen-engine means evaluations are serialized through one thread. This is acceptable for child welfare workloads (low evaluation volume) but would need architectural changes for high-throughput scenarios. -
Cache invalidation overhead: Every rule set mutation publishes a
rules.cache_invalidatedevent, causing all instances to reload all rule sets from the database. This is a simple but heavy-handed approach. (To be superseded in part by the #1188 amendment below — design accepted, implementation tracked in that plan: peers are to reconcile incrementally instead of wholesale-reloading, with a periodic sweep bounding staleness when the event is lost.) -
GoRules dependency: While JDM is JSON-based, the visual editor for authoring complex rule sets is a commercial product (GoRules). Simple rule sets can be authored in any JSON editor.
Amendment — #1188 (bounded staleness; identity-aware CAS cache protocol)
The event-only refresh model above has an unbounded failure mode (W4b, external
review, CONFIRMED): rule-set writes auto-commit and the invalidation events are
staged in separate warn!-only transactions, so a lost rules.cache_invalidated
leaves a scaled peer’s compiled-decision cache stale until restart — evaluate()
never consults the DB on a hit, and a miss is a hard RuleSetNotFound (there has
never been a lazy-load path; older comments claiming "repopulate on next miss"
were wrong — U1’s comment sweep corrects them). This amendment records the
ACCEPTED DESIGN; implementation lands as the plan’s U1/U2 (Status there is
authoritative for what has shipped):
-
The enforced change token is the pair
(id, revision).rule_sets.revision(BIGINT, DEFAULT 1) is bumped by aBEFORE UPDATEtrigger — monotonic per row for every writer including direct SQL. Revision ALONE is insufficient: the cache is name-keyed,nameis UNIQUE-total and rename frees it, and a recreate under a freed name starts at revision 1 — only the id half detects rebinding. -
Every cache mutation is a conditional apply (
craig_rules::decision_refresh, a self-contained lib module the bin and the integration tests both drive): install if absent, or id differs (name rebound), or same-id-higher-revision. Removal only after a fresh per-name re-probe confirms absent/inactive. The API insert path, the cross-instance subscriber (becomesreconcile, replacingreload_all— the wholesale swap could overwrite a concurrent local mutation), and the periodic sweep all speak this protocol;reload_allbecomes boot-only. -
The periodic reconciler bounds lost-event staleness: a probe of
(name, id, revision)for active rows, diffed against the cache; refreshes and confirmed removals applied per name; fail-fast decomposed report. Interval knobCRAIG_RULES__DECISION_REFRESH_SECONDS(default 300, devstack 15). The honest bound: under a healthy DB with compilable rows, staleness after a lost event is ≤ ~2×interval + pass duration (≈10min at the default; ≈30s in devstack) — never claimed as "interval seconds". -
Fail-closed compile policy: a changed row whose stored content no longer compiles EVICTS the cached entry (
error!+ report) — a 404 beats serving superseded policy. Only direct-SQL writes can produce this; the API pre-validates. -
Recorded residuals (bounded ≤1 pass, self-correcting, both from the same re-probe/apply window; the generation-guard/tombstone that would close them fully is declined at this severity): a wrong-removal window (fail-closed interim) and its mirror, a stale re-install after a concurrent delete (fail-open interim, narrowed to milliseconds by the active-filtered fetch). The ROOT residual — non-transactional event staging — was CLOSED by #1216 (2026-08-03): all four rule-set mutation paths (create / update / delete / import) stage
rules.cache_invalidated+ruleset.changed.<name>in the SAME transaction as the row write (RulesEngine::stage_cache_invalidated/stage_ruleset_changedjoin the caller’s tx; a staging failure rolls the mutation back — the one-shot warn!-only notify helpers are retired). The sweep remains as the backstop for DELIVERY-side loss (outbox → broker → consumer) and for the originator’s own narrow post-commit window (the local cache apply runs after the tx; a failure there 500s with row + events already committed, and the originator skips its own event) — no longer for staging loss, which is unrepresentable.
Implementation plan: Decision-Cache Sweep (the CAS truth tables, test matrix, and review-round log live there).
Amendment — #1048 (failure/attempt audit rows; the restored invariant)
#784 weakened this ADR’s original "every evaluation is recorded" claim to completed-evaluation wording because it was false. #1048 restores the stronger invariant as a designed feature. The semantics, in the order they bite:
-
Failure rows ride their OWN short transaction — never the caller’s. The inbox path (
evaluate_in_tx, ADR-062 A5) rolls its attempt tx back on this very error; the forensic row must survive, and each retried attempt leaves one row by design. The success path is unchanged: #1130’s fail-closed posture (no response until the row commits) still holds for decisions. -
Failure audit is best-effort, the deliberate inverse of #1130. A failure response must carry the ORIGINAL error; an audit-write failure is WARN’d, never allowed to mask it. (A success is not served without its row; a failure is never hidden behind its row’s failure.)
-
Late completion converges, it never inserts. The caller mints ONE evaluation id per attempt and writes the
timeoutrow on the error path; the zen loop routes a dead-oneshot SUCCESS to a recorder task that flips that row tolate_completed(UPDATE-only, bounded retry — the trade of a narrow no-row WARN window against cloning every request’s input through the eval channel for a rare event). A late FAILURE adds nothing over the timeout row and is dropped. The recorder is supervised Observed (eval-late-recorder); its loss degrades convergence, corrupts nothing. -
error_detailis PII-safe by construction: engine error text only — ruleset-authored content and engine internals; caller input is never interpolated intoEngineErrormessages (the input itself lands in the row’sinputcolumn exactly as completed rows do, under the same access control). -
The MQ half is explicitly scoped OUT.
rules.evaluatedstays success-only: failure rows are queryable atGET /v1/rules/evaluations?disposition=…and ride the #1129 archive tier, and a newrules.evaluation_failedevent would create consumer obligations (security audit_log copies, retention, replay) with no present consumer need. Revisit only with a concrete consumer. -
Wire surface:
RuleEvaluation.outputbecame nullable and the rows carrydisposition/error_detail/budget_ms/dispatched(pre-1.0 breaking, CHANGELOG’d); the list endpoint filters by disposition with an allow-list 400 on unknown values.
Amendment — #1046 (wedge posture: owned bounds, deadman, restart — no respawn)
#784 made a wedged evaluation visible (the dispatch budget) and #1048 made it
audited (the timeout row); #1046 settles what the service DOES about one.
Source recon against the vendored zen-engine 0.54 first narrowed the threat:
JS function nodes are interrupt-bounded upstream (a hard 500ms handler for v1
string-content nodes; the process-global function_timeout_millis for v2
{source} nodes), graph recursion is depth-bounded (max_depth, pinned at 10
by #809), and the expression DSL has no unbounded loops. The permanent-wedge
class the issue was filed against is therefore mostly unreachable BY DESIGN —
what remains is pathological-but-finite work and genuine zen/rquickjs bugs.
The posture, in layers:
-
Own the bound instead of inheriting it. The 5s upstream
function_timeout_millisdefault was a load-bearing safety property nobody set. Boot now publishesCRAIG_RULES__FUNCTION_TIMEOUT_MSintozen_engine::ZEN_CONFIG(default =min(5000, eval budget); an explicit value must sit in1..=eval budget— a JS node outliving the dispatch budget would turn a clean typedruntime_errorrow into a caller timeout). The bound is per-NODE: a multi-JS-node graph can legitimately sum past it, and the #784 budget stays the per-dispatch caller ceiling. The setting is process-global, so craig-rules' own embedded authz engine is covered; every OTHER service’s embedded authz eval thread still inherits the default silently — the fleet-wide mirror is #1328. -
Detect permanence with a precise signal, not a proxy. The eval loop stamps its single in-flight evaluation (id + start instant); a watchdog samples it and fires when ONE evaluation has run past
max(10 × eval budget, 60s). Queue wait never stamps the slot, so backlog cannot trip it — unlike the rejected consecutive-EvalTimeout counter, which false-positives on load bursts. The threshold is deliberately generous: it detects a bound-escaping BUG (every designed bound sits far below it), not slowness — latency remains the #784 budget’s job. -
Recovery is platform restart through the existing health surface. The deadman widens the SAME
zen-evalCritical liveness signal #1228 registered (thread death OR proven wedge), riding ADR-061 fail-fast: supervisor shutdown, nonzero exit, orchestrator restart. The eval thread is stateless (compiled decisions live engine-side), so a restart loses nothing but an unconsumable late flip — already a WARN’d #1048 loss window. -
Rejected: in-process respawn. A wedged
stdthread cannot be cancelled; respawning would abandon it alive — a leaked core, a leaked runtime, unreclaimable C-level rquickjs state, unbounded across repeated wedges — while the process claims health. That is running on a corrupted runtime and calling it self-healing. Also rejected: an N-thread eval pool (decorrelates wedges without fixing one; no observed throughput need) and the consecutive-timeout detector (above).
Evidence shape: the boundedness pin (an infinite JS loop terminates as a
typed runtime_error row end-to-end), the knob-efficacy pin (a v2 spin
under an injected 250ms published bound terminates far below the 5s
upstream default — the atomic is consulted at evaluate time, not merely
readable), the deadman condition matrix, and a real-topology detection
proof (a sub-second injected threshold catches the v1 spin on the genuine
loop/watchdog/sentinel chain while the thread stays alive — the resolution
is provably the deadman, not death).
Amendment — #1556 (D2: revision stamping; the pinned-replay carve-out)
Every evaluation row (and the rules.evaluated pointer payload) now stamps
rule_set_revision from the cache entry that ran — evaluation → exact
rule_set_snapshots content is a total join for post-D2 rows. Pinned
evaluations (snapshot replays, supervisor+ gated) run compile-per-call
outside the name-keyed CAS cache, stamp the snapshot identity
pinned = true, and stage NO rules.evaluated event (non-operative by
doctrine). Their refusal classes (absent snapshot, compile failure under
the current engine) are typed and fire before an evaluation id exists.
Full as-built: ADR-066 § Amendment #1556.