Plan: Rules Engine Implementation
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Database schema (rule_sets, rule_evaluations) |
Done (2026-04-03) |
2 |
Rules engine core (zen-engine integration, in-memory cache, dedicated thread) |
Done (2026-04-03) |
3 |
Store layer (database query functions) |
Done (2026-04-03) |
4 |
CRUD + evaluation API endpoints |
Done (2026-04-03) |
5 |
Ruleset import from rulesets/{jurisdiction}/ directory on startup |
Done (2026-04-03) — (scans JSON files, skips existing, imports with name from JDM |
6 |
Tests (unit + integration with zen-engine evaluation) |
Done (2026-04-03) |
Epic: &33, &38
Branch: feature/rules-engine
MR: !8
Context
ADR-003 requires that all eligibility logic live in versioned JDM ruleset files, evaluated by a shared rules engine. No program service implements its own rules evaluation. Program services call canopy-rules with a ruleset name and input context, receive structured output, and use that output to produce a determination.
This plan ports the craig-rules pattern — zen-engine with an in-memory decision cache, a dedicated single-threaded runtime (zen-engine produces !Send futures), CRUD endpoints for ruleset management, and an evaluation audit trail.
canopy-rules must be operational before any program service can evaluate eligibility.
The 11 stub rulesets under rulesets/georgia/ were created during scaffolding.
This plan makes them loadable and evaluable.
CRAIG Reference
The implementation is ported from CRAIG’s rules service:
-
d:/code/craig/services/craig-rules/src/engine.rs(232 lines) — proven pattern for running zen-engine’s!Sendfutures on a dedicated OS thread -
d:/code/craig/services/craig-rules/src/store.rs(283 lines) — CRUD and evaluation audit trail queries -
d:/code/craig/services/craig-rules/src/api.rs(605 lines) — Axum handler pattern with engine as Extension -
d:/code/craig/services/craig-rules/src/main.rs(174 lines) — bootstrap, migration, event wiring, cache invalidation
Canopy adapts these to the canopy-api/canopy-db/canopy-mq crate ecosystem, strips CRAIG-specific domain events (eligibility.submitted, case.intake_created, placement.requested), and replaces craig_common::id::new_id() with the canopy equivalent.
Scope
In scope:
-
rule_setstable: name, description, version, content (JSONB), active flag -
rule_evaluationstable: audit trail of every evaluation (input, output, duration, ruleset name, context) -
zen-engine integration with dedicated OS thread + single-threaded tokio runtime
-
In-memory HashMap cache of compiled decisions keyed by ruleset name
-
CRUD endpoints: list, create, get, update, soft-delete, import, export
-
Evaluate endpoint:
POST /v1/evaluate— accepts ruleset name + input JSON, returns output -
Evaluation audit trail:
GET /v1/evaluationswith filtering -
Ruleset import: bulk load from
rulesets/{jurisdiction}/*.json -
Cache invalidation event for horizontal scaling (
rules.cache_invalidated)
Out of scope:
-
Domain-specific event subscriptions (e.g., "eligibility.submitted" → auto-evaluate) — added per program service plan
-
Ruleset authoring UI — rulesets are authored in zen-engine JDM format and committed to the repository
Design
Engine Architecture
The core challenge is that zen-engine’s DecisionGraph evaluations produce futures that are !Send because the library internally uses Rc.
The solution (proven in craig-rules) is a dedicated OS thread running a single-threaded tokio runtime, communicating via an mpsc channel.
pub struct RulesEngine {
decisions: Arc<RwLock<HashMap<String, Arc<ZenDecision>>>>,
eval_tx: tokio::sync::mpsc::Sender<EvalRequest>,
instance_id: Arc<String>,
db: DbPool,
publisher: Option<Publisher>,
jurisdiction: Arc<String>,
}
struct EvalRequest {
decision: Arc<ZenDecision>,
input: serde_json::Value,
reply: tokio::sync::oneshot::Sender<Result<serde_json::Value, anyhow::Error>>,
}
The engine spawns a background OS thread on construction:
std::thread::Builder::new()
.name("zen-eval".into())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build zen-eval runtime");
rt.block_on(eval_loop(eval_rx));
})?;
The evaluation loop runs on that dedicated thread:
async fn eval_loop(mut rx: mpsc::Receiver<EvalRequest>) {
while let Some(req) = rx.recv().await {
let result = req
.decision
.evaluate((&req.input).into())
.await
.map(|resp| serde_json::to_value(&resp.result).unwrap_or(serde_json::Value::Null))
.map_err(|e| anyhow::anyhow!("evaluation failed: {e}"));
let _ = req.reply.send(result);
}
}
API Endpoints
| Method | Path | Description |
|---|---|---|
GET |
/v1/rule-sets |
List rule sets (paginated, searchable) |
POST |
/v1/rule-sets |
Create a rule set |
GET |
/v1/rule-sets/{id} |
Get a rule set |
PUT |
/v1/rule-sets/{id} |
Update a rule set |
DELETE |
/v1/rule-sets/{id} |
Soft-delete a rule set |
POST |
/v1/rule-sets/{id}/import |
Replace content, auto-version |
GET |
/v1/rule-sets/{id}/export |
Download as JSON attachment |
POST |
/v1/evaluate |
Evaluate input against a named rule set |
GET |
/v1/evaluations |
List evaluations (paginated, filterable by rule set) |
Events
-
rules.evaluated— payload:{ rule_set_name, context_type, context_id, duration_ms } -
rules.cache_invalidated— payload:{ instance_id, rule_set_name, action }(for horizontal scaling)
No input/output data in event payloads — IDs and metadata only.
CLI Commands (ADR-007)
Per ADR-007, the following canopy CLI commands must be added to tools/canopy-cli/ when this plan ships:
-
canopy rules list— list rule sets (paginated, searchable) -
canopy rules create— create a rule set -
canopy rules get <id>— get a rule set -
canopy rules update <id>— update a rule set -
canopy rules delete <id>— soft-delete a rule set -
canopy rules import <id> <file>— replace content from file, auto-version -
canopy rules export <id>— download rule set as JSON -
canopy rules evaluate— evaluate input against a named rule set -
canopy rules evaluations— list evaluations (paginated, filterable)
Steps
Step 1: Database Migration
Files: services/canopy-rules/migrations/20260326000000_create_rules_tables.sql
Create the two core tables and their indexes. The schema follows the CRAIG migration at d:/code/craig/services/craig-rules/migrations/20240101000000_create_rule_sets.sql with two canopy-specific changes: (1) duration_ms added to rule_evaluations for performance tracking; (2) version defaults to 'v1.0' since canopy rulesets are file-imported rather than API-created.
CREATE TABLE rule_sets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT,
version TEXT NOT NULL DEFAULT 'v1.0',
content JSONB NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE rule_evaluations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rule_set_name TEXT NOT NULL,
context_type TEXT,
context_id UUID,
input JSONB NOT NULL,
output JSONB NOT NULL,
duration_ms INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_rule_evaluations_rule_set_name ON rule_evaluations(rule_set_name);
CREATE INDEX idx_rule_evaluations_context ON rule_evaluations(context_type, context_id);
Notes:
-
No
created_by/updated_bycolumns yet — canopy does not have auth wired. These will be added when canopy-auth lands. -
rule_evaluations.rule_set_nameis denormalized (not a FK) so evaluations survive rule set deletion. -
duration_msis recorded by the engine by timingInstant::now().elapsed()around the evaluate call.
Verification:
# After cargo xtask dev restart:
psql $DATABASE_URL -c "\d rule_sets"
psql $DATABASE_URL -c "\d rule_evaluations"
# Both tables exist with correct columns and indexes.
Step 2: Engine Core
Files: services/canopy-rules/src/engine.rs
Port from d:/code/craig/services/craig-rules/src/engine.rs (232 lines). The CRAIG implementation is the proven pattern for running zen-engine’s !Send futures. Canopy’s version drops the evaluated_by parameter (no auth yet) and adds duration_ms tracking.
Full struct and method signatures
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{RwLock, mpsc, oneshot};
use tracing::{info, warn};
use uuid::Uuid;
use zen_engine::Decision;
use zen_engine::model::DecisionContent;
use canopy_db::DbPool;
use canopy_mq::{EventEnvelope, Publisher};
use crate::store;
type ZenDecision = Decision;
/// Request sent to the dedicated evaluation thread.
struct EvalRequest {
decision: Arc<ZenDecision>,
input: serde_json::Value,
reply: oneshot::Sender<Result<serde_json::Value, anyhow::Error>>,
}
/// In-memory rules engine backed by GoRules zen-engine.
///
/// Loads JDM rule sets from the database and caches compiled decisions.
/// Each evaluation records an audit trail in the `rule_evaluations` table.
///
/// Evaluations run on a dedicated thread with a single-threaded tokio runtime
/// because zen-engine's `evaluate()` future is `!Send` (uses `Rc` internally).
#[derive(Clone)]
pub struct RulesEngine {
db: DbPool,
decisions: Arc<RwLock<HashMap<String, Arc<ZenDecision>>>>,
eval_tx: mpsc::Sender<EvalRequest>,
publisher: Option<Publisher>,
jurisdiction: Arc<String>,
instance_id: Arc<String>,
}
Constructor with dedicated thread spawn
The constructor spawns the OS thread, loads all active rule sets from the database, and returns the engine ready to evaluate.
impl RulesEngine {
/// Create a new RulesEngine and load all active rule sets from the database.
pub async fn new(
db: DbPool,
publisher: Option<Publisher>,
jurisdiction: &str,
) -> anyhow::Result<Self> {
let (eval_tx, eval_rx) = mpsc::channel::<EvalRequest>(256);
// Spawn a dedicated OS thread running a single-threaded tokio runtime
// for zen-engine evaluations (which produce !Send futures).
std::thread::Builder::new()
.name("zen-eval".into())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build zen-eval runtime");
rt.block_on(eval_loop(eval_rx));
})?;
let engine = Self {
db,
decisions: Arc::new(RwLock::new(HashMap::new())),
eval_tx,
publisher,
jurisdiction: Arc::new(jurisdiction.to_owned()),
instance_id: Arc::new(Uuid::now_v7().to_string()),
};
engine.reload_all().await?;
Ok(engine)
}
reload_all — bulk cache load from database
/// Reload all active rule sets from the database into the in-memory cache.
pub async fn reload_all(&self) -> anyhow::Result<()> {
let rule_sets = store::list_rule_sets(self.db.inner()).await?;
let mut cache: HashMap<String, Arc<ZenDecision>> = HashMap::new();
for rs in &rule_sets {
match Self::compile_rule_set(&rs.content) {
Ok(decision) => {
cache.insert(rs.name.clone(), Arc::new(decision));
info!(name = %rs.name, version = %rs.version, "rule set loaded");
}
Err(e) => {
warn!(name = %rs.name, error = %e, "failed to compile rule set, skipping");
}
}
}
info!(count = cache.len(), "rules engine initialized");
*self.decisions.write().await = cache;
Ok(())
}
insert_decision / remove_decision — incremental cache mutations
/// Insert or replace a single compiled decision in the cache.
pub async fn insert_decision(&self, name: String, decision: ZenDecision) {
self.decisions
.write()
.await
.insert(name, Arc::new(decision));
}
/// Remove a single decision from the cache by name.
pub async fn remove_decision(&self, name: &str) {
self.decisions.write().await.remove(name);
}
evaluate — the main entry point
Looks up the compiled decision, sends it to the dedicated thread via the mpsc channel, waits for the result via a oneshot, records the audit trail, and publishes an event.
/// Evaluate an input against a named rule set.
///
/// Records an audit trail in rule_evaluations and publishes a
/// rules.evaluated event (best-effort).
pub async fn evaluate(
&self,
rule_set_name: &str,
context_type: Option<&str>,
context_id: Option<Uuid>,
input: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let decision: Arc<ZenDecision> = self
.decisions
.read()
.await
.get(rule_set_name)
.ok_or_else(|| anyhow::anyhow!("rule set not found: {rule_set_name}"))?
.clone();
let start = Instant::now();
let (reply_tx, reply_rx) = oneshot::channel();
self.eval_tx
.send(EvalRequest {
decision,
input: input.clone(),
reply: reply_tx,
})
.await
.map_err(|_| anyhow::anyhow!("evaluation thread unavailable"))?;
let output = reply_rx
.await
.map_err(|_| anyhow::anyhow!("evaluation thread dropped response"))??;
let duration_ms = start.elapsed().as_millis() as i32;
// Record audit trail.
store::record_evaluation(
self.db.inner(),
rule_set_name,
&input,
&output,
duration_ms,
context_type,
context_id,
)
.await?;
// Publish rules.evaluated event (best-effort -- don't fail the evaluation).
if let Some(ref publisher) = self.publisher {
let payload = serde_json::json!({
"rule_set_name": rule_set_name,
"context_type": context_type,
"context_id": context_id,
"duration_ms": duration_ms,
});
let envelope = EventEnvelope::new("canopy-rules", "rules.evaluated", payload);
if let Err(e) = publisher.publish(&envelope).await {
warn!(error = %e, "failed to publish rules.evaluated event");
}
}
Ok(output)
}
compile_rule_set — JDM JSON to zen-engine Decision
/// Compile a JDM rule set JSON value into a zen-engine Decision.
pub fn compile_rule_set(content: &serde_json::Value) -> anyhow::Result<ZenDecision> {
let decision_content: DecisionContent = serde_json::from_value(content.clone())?;
Ok(ZenDecision::from(decision_content))
}
instance_id / notify_cache_invalidated — horizontal scaling
/// Unique identifier for this engine instance.
pub fn instance_id(&self) -> &str {
&self.instance_id
}
/// Notify other instances that the rule set cache has changed.
///
/// Publishes a `rules.cache_invalidated` event so that horizontally-scaled
/// instances can reload their in-memory caches from the database.
pub async fn notify_cache_invalidated(&self, rule_set_name: &str, action: &str) {
if let Some(ref publisher) = self.publisher {
let envelope = EventEnvelope::new(
"canopy-rules",
"rules.cache_invalidated",
serde_json::json!({
"instance_id": *self.instance_id,
"rule_set_name": rule_set_name,
"action": action,
}),
);
if let Err(e) = publisher.publish(&envelope).await {
warn!(error = %e, "failed to publish rules.cache_invalidated event");
}
}
}
}
eval_loop — the dedicated thread event loop
/// Event loop running on the dedicated zen-engine thread.
async fn eval_loop(mut rx: mpsc::Receiver<EvalRequest>) {
while let Some(req) = rx.recv().await {
let result = req
.decision
.evaluate((&req.input).into())
.await
.map(|resp| serde_json::to_value(&resp.result).unwrap_or(serde_json::Value::Null))
.map_err(|e| anyhow::anyhow!("evaluation failed: {e}"));
let _ = req.reply.send(result);
}
}
Key differences from CRAIG:
-
Drops
evaluated_byparameter (no auth yet). -
Adds
duration_mstiming viaInstant::now(). -
notify_cache_invalidatedtakesrule_set_nameandactionparameters (CRAIG’s version sends onlyinstance_id). -
Event source is
"canopy-rules"not"craig-rules".
Step 3: Store Layer
Files: services/canopy-rules/src/store.rs
Follows the store pattern from d:/code/craig/services/craig-rules/src/store.rs (283 lines). Canopy’s version drops created_by/updated_by columns (no auth yet) and adds duration_ms to evaluations.
Model structs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
/// A persisted JDM rule set.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct RuleSet {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub version: String,
pub content: serde_json::Value,
pub active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Audit record for a rule evaluation.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct RuleEvaluation {
pub id: Uuid,
pub rule_set_name: String,
pub context_type: Option<String>,
pub context_id: Option<Uuid>,
pub input: serde_json::Value,
pub output: serde_json::Value,
pub duration_ms: i32,
pub created_at: DateTime<Utc>,
}
Full function signatures and SQL
/// List all active rule sets (used internally by the engine cache reload).
pub async fn list_rule_sets(pool: &PgPool) -> Result<Vec<RuleSet>, sqlx::Error> {
sqlx::query_as::<_, RuleSet>(
"SELECT * FROM rule_sets WHERE active = true ORDER BY name"
)
.fetch_all(pool)
.await
}
/// List active rule sets with pagination and optional search.
pub async fn list_rule_sets_paged(
pool: &PgPool,
limit: i64,
offset: i64,
search: Option<&str>,
sort_by: Option<&str>,
sort_dir: Option<&str>,
) -> Result<Vec<RuleSet>, sqlx::Error> {
let col = match sort_by {
Some("name") => "name",
Some("version") => "version",
_ => "created_at",
};
let dir = match sort_dir {
Some("asc") | Some("ASC") => "ASC",
_ => "DESC",
};
let sql = format!(
r#"SELECT * FROM rule_sets
WHERE active = true
AND ($1::TEXT IS NULL
OR name ILIKE '%' || $1 || '%'
OR COALESCE(description, '') ILIKE '%' || $1 || '%')
ORDER BY {col} {dir}
LIMIT $2 OFFSET $3"#
);
sqlx::query_as::<_, RuleSet>(&sql)
.bind(search)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
}
/// Count active rule sets, optionally filtered by search term.
pub async fn count_rule_sets(
pool: &PgPool,
search: Option<&str>,
) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM rule_sets
WHERE active = true
AND ($1::TEXT IS NULL
OR name ILIKE '%' || $1 || '%'
OR COALESCE(description, '') ILIKE '%' || $1 || '%')"#,
)
.bind(search)
.fetch_one(pool)
.await?;
Ok(row.0)
}
/// Get a rule set by ID.
pub async fn get_rule_set(
pool: &PgPool,
id: Uuid,
) -> Result<Option<RuleSet>, sqlx::Error> {
sqlx::query_as::<_, RuleSet>("SELECT * FROM rule_sets WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
}
/// Get a rule set by name (active only).
pub async fn get_rule_set_by_name(
pool: &PgPool,
name: &str,
) -> Result<Option<RuleSet>, sqlx::Error> {
sqlx::query_as::<_, RuleSet>(
"SELECT * FROM rule_sets WHERE name = $1 AND active = true"
)
.bind(name)
.fetch_optional(pool)
.await
}
/// Insert a new rule set.
pub async fn create_rule_set(
pool: &PgPool,
name: &str,
description: Option<&str>,
content: &serde_json::Value,
) -> Result<RuleSet, sqlx::Error> {
sqlx::query_as::<_, RuleSet>(
r#"INSERT INTO rule_sets (id, name, description, content)
VALUES (gen_random_uuid(), $1, $2, $3)
RETURNING *"#,
)
.bind(name)
.bind(description)
.bind(content)
.fetch_one(pool)
.await
}
/// Update an existing active rule set (partial update via COALESCE).
pub async fn update_rule_set(
pool: &PgPool,
id: Uuid,
name: Option<&str>,
description: Option<&str>,
content: Option<&serde_json::Value>,
version: Option<&str>,
) -> Result<Option<RuleSet>, sqlx::Error> {
sqlx::query_as::<_, RuleSet>(
r#"UPDATE rule_sets
SET name = COALESCE($2, name),
description = COALESCE($3, description),
content = COALESCE($4, content),
version = COALESCE($5, version),
updated_at = now()
WHERE id = $1 AND active = true
RETURNING *"#,
)
.bind(id)
.bind(name)
.bind(description)
.bind(content)
.bind(version)
.fetch_optional(pool)
.await
}
/// Soft-delete a rule set by setting active = false.
/// Returns the name of the deactivated rule set, or None if not found.
pub async fn deactivate_rule_set(
pool: &PgPool,
id: Uuid,
) -> Result<Option<String>, sqlx::Error> {
sqlx::query_scalar::<_, String>(
r#"UPDATE rule_sets
SET active = false, updated_at = now()
WHERE id = $1 AND active = true
RETURNING name"#,
)
.bind(id)
.fetch_optional(pool)
.await
}
/// Record a rule evaluation for audit purposes.
pub async fn record_evaluation(
pool: &PgPool,
rule_set_name: &str,
input: &serde_json::Value,
output: &serde_json::Value,
duration_ms: i32,
context_type: Option<&str>,
context_id: Option<Uuid>,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"INSERT INTO rule_evaluations
(id, rule_set_name, input, output, duration_ms, context_type, context_id)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6)"#,
)
.bind(rule_set_name)
.bind(input)
.bind(output)
.bind(duration_ms)
.bind(context_type)
.bind(context_id)
.execute(pool)
.await?;
Ok(())
}
/// List evaluations with optional filters and pagination.
pub async fn list_evaluations_paged(
pool: &PgPool,
limit: i64,
offset: i64,
rule_set_name: Option<&str>,
context_type: Option<&str>,
context_id: Option<Uuid>,
) -> Result<Vec<RuleEvaluation>, sqlx::Error> {
sqlx::query_as::<_, RuleEvaluation>(
r#"SELECT * FROM rule_evaluations
WHERE ($1::TEXT IS NULL OR rule_set_name = $1)
AND ($2::TEXT IS NULL OR context_type = $2)
AND ($3::UUID IS NULL OR context_id = $3)
ORDER BY created_at DESC
LIMIT $4 OFFSET $5"#,
)
.bind(rule_set_name)
.bind(context_type)
.bind(context_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
}
/// Count evaluations matching optional filters.
pub async fn count_evaluations(
pool: &PgPool,
rule_set_name: Option<&str>,
context_type: Option<&str>,
context_id: Option<Uuid>,
) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM rule_evaluations
WHERE ($1::TEXT IS NULL OR rule_set_name = $1)
AND ($2::TEXT IS NULL OR context_type = $2)
AND ($3::UUID IS NULL OR context_id = $3)"#,
)
.bind(rule_set_name)
.bind(context_type)
.bind(context_id)
.fetch_one(pool)
.await?;
Ok(row.0)
}
Step 4: API Routes
Files: services/canopy-rules/src/api.rs (replaces existing stub api/mod.rs)
Port API handler pattern from d:/code/craig/services/craig-rules/src/api.rs (605 lines). Canopy’s version drops auth/claims (no require_role checks yet) and uses canopy-api’s AppState.
Route tree
use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use canopy_api::{ApiError, AppState, PageResponse};
use serde::Deserialize;
use uuid::Uuid;
use crate::engine::RulesEngine;
use crate::store;
/// Build the rules service route tree.
pub fn routes(engine: RulesEngine) -> Router<AppState> {
Router::new()
.route("/v1/rule-sets", get(list_rule_sets).post(create_rule_set))
.route(
"/v1/rule-sets/{id}",
get(get_rule_set).put(update_rule_set).delete(delete_rule_set),
)
.route("/v1/rule-sets/{id}/import", post(import_rule_set))
.route("/v1/rule-sets/{id}/export", get(export_rule_set))
.route("/v1/evaluate", post(evaluate))
.route("/v1/evaluations", get(list_evaluations))
.layer(Extension(engine))
}
Handler signatures
async fn list_rule_sets(
State(app): State<AppState>,
Query(query): Query<RuleSetQuery>,
) -> Result<Json<PageResponse<store::RuleSet>>, ApiError>
async fn get_rule_set(
State(app): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<store::RuleSet>, ApiError>
async fn create_rule_set(
State(app): State<AppState>,
Extension(engine): Extension<RulesEngine>,
Json(body): Json<CreateRuleSetRequest>,
) -> Result<Json<store::RuleSet>, ApiError>
async fn update_rule_set(
State(app): State<AppState>,
Extension(engine): Extension<RulesEngine>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateRuleSetRequest>,
) -> Result<Json<store::RuleSet>, ApiError>
async fn delete_rule_set(
State(app): State<AppState>,
Extension(engine): Extension<RulesEngine>,
Path(id): Path<Uuid>,
) -> Result<Response, ApiError>
async fn import_rule_set(
State(app): State<AppState>,
Extension(engine): Extension<RulesEngine>,
Path(id): Path<Uuid>,
Json(content): Json<serde_json::Value>,
) -> Result<Json<store::RuleSet>, ApiError>
async fn export_rule_set(
State(app): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Response, ApiError>
async fn evaluate(
Extension(engine): Extension<RulesEngine>,
Json(body): Json<EvaluateRequest>,
) -> Result<Json<EvaluateResponse>, ApiError>
async fn list_evaluations(
State(app): State<AppState>,
Query(query): Query<EvaluationQuery>,
) -> Result<Json<PageResponse<store::RuleEvaluation>>, ApiError>
Request/response types
#[derive(Deserialize)]
struct CreateRuleSetRequest {
name: String,
description: Option<String>,
content: serde_json::Value,
}
#[derive(Deserialize)]
struct UpdateRuleSetRequest {
name: Option<String>,
description: Option<String>,
content: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct EvaluateRequest {
rule_set_name: String,
input: serde_json::Value,
context_type: Option<String>,
context_id: Option<Uuid>,
}
#[derive(serde::Serialize)]
struct EvaluateResponse {
rule_set_name: String,
output: serde_json::Value,
duration_ms: i32,
evaluation_id: Uuid,
}
Example JSON: POST /v1/evaluate
Request:
{
"rule_set_name": "snap-eligibility",
"context_type": "application",
"context_id": "019513a2-b3f4-7000-8000-000000000001",
"input": {
"gross_monthly_income": 1800,
"household_size": 3,
"countable_assets": 1500
}
}
Response (200 OK):
{
"rule_set_name": "snap-eligibility",
"output": {
"result": "pass",
"eligible": true,
"basis": "gross_income_under_130pct_fpl"
},
"duration_ms": 2,
"evaluation_id": "019513b1-c4e5-7000-8000-000000000002"
}
Example JSON: POST /v1/rule-sets
Request:
{
"name": "georgia-snap-eligibility",
"description": "SNAP eligibility: gross income, net income, asset tests",
"content": { "nodes": [...], "edges": [...] }
}
Response (200 OK):
{
"id": "019513a0-a1b2-7000-8000-000000000001",
"name": "georgia-snap-eligibility",
"description": "SNAP eligibility: gross income, net income, asset tests",
"version": "v1.0",
"content": { "nodes": [...], "edges": [...] },
"active": true,
"created_at": "2026-03-26T14:30:00Z",
"updated_at": "2026-03-26T14:30:00Z"
}
Handler behavior notes
-
create_rule_set: Calls
RulesEngine::compile_rule_set(&body.content)before persisting — returns 400 if the JDM content is invalid. After DB insert, callsengine.insert_decision()andengine.notify_cache_invalidated(). Returns 409 on duplicate name (catchesrule_sets_name_keyconstraint violation). -
update_rule_set: Fetches the old record first (needs old name for cache key removal). If name or content changed, removes old key and inserts new compiled decision.
-
delete_rule_set: Calls
store::deactivate_rule_set(), thenengine.remove_decision(). Returns 204 No Content. -
import_rule_set: Replaces content and auto-generates a timestamp version (
chrono::Utc::now().format("%Y%m%d%H%M%S")). -
export_rule_set: Returns
Content-Disposition: attachment; filename="{name}.json". -
evaluate: Delegates to
engine.evaluate(). Maps "not found" errors to 404, everything else to 500.
Step 5: Ruleset Import
Files: services/canopy-rules/src/main.rs
On startup, after engine initialization and migration, scan rulesets/{jurisdiction}/ and import any rulesets not already in the database. The jurisdiction comes from ServiceSettings.jurisdiction.
Full startup import code
// In main.rs after engine initialization:
let ruleset_dir = format!("rulesets/{}", settings.jurisdiction);
if let Ok(entries) = std::fs::read_dir(&ruleset_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(false, |e| e == "json") {
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let content: serde_json::Value = serde_json::from_str(&raw)
.with_context(|| format!("failed to parse {}", path.display()))?;
let name = content["name"]
.as_str()
.unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
});
// Only import if not already in the database.
if store::get_rule_set_by_name(boot.db.inner(), name).await?.is_none() {
let description = content["_comment"].as_str();
store::create_rule_set(boot.db.inner(), name, description, &content).await
.with_context(|| format!("failed to import ruleset '{name}'"))?;
// Compile and insert into the in-memory cache.
let decision = engine::RulesEngine::compile_rule_set(&content)
.with_context(|| format!("failed to compile ruleset '{name}'"))?;
rules_engine.insert_decision(name.to_owned(), decision).await;
info!(name, path = %path.display(), "imported ruleset from disk");
}
}
}
}
Updated main.rs structure
// SPDX-License-Identifier: AGPL-3.0-or-later
#![forbid(unsafe_code)]
mod api;
mod engine;
mod events;
mod store;
use anyhow::Context;
use canopy_api::{ApiServer, AppState, ServerOptions, shutdown_signal};
use canopy_mq::EventEnvelope;
use tracing::info;
use uuid::Uuid;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (settings, boot) = canopy_api::bootstrap("CANOPY_RULES", "canopy-rules").await?;
// Run database migrations.
boot.db
.run_migrations(&sqlx::migrate!())
.await
.context("failed to run database migrations")?;
// Initialize rules engine (spawns dedicated zen-eval thread).
let rules_engine = engine::RulesEngine::new(
boot.db.clone(),
Some(boot.publisher),
&settings.jurisdiction,
)
.await
.context("failed to initialize rules engine")?;
// Import rulesets from disk (rulesets/{jurisdiction}/*.json).
let ruleset_dir = format!("rulesets/{}", settings.jurisdiction);
if let Ok(entries) = std::fs::read_dir(&ruleset_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(false, |e| e == "json") {
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let content: serde_json::Value = serde_json::from_str(&raw)
.with_context(|| format!("failed to parse {}", path.display()))?;
let name = content["name"]
.as_str()
.unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
});
if store::get_rule_set_by_name(boot.db.inner(), name)
.await?
.is_none()
{
let description = content["_comment"].as_str();
store::create_rule_set(boot.db.inner(), name, description, &content)
.await
.with_context(|| format!("failed to import ruleset '{name}'"))?;
let decision = engine::RulesEngine::compile_rule_set(&content)
.with_context(|| format!("failed to compile ruleset '{name}'"))?;
rules_engine
.insert_decision(name.to_owned(), decision)
.await;
info!(name, path = %path.display(), "imported ruleset from disk");
}
}
}
}
// Subscribe to cache invalidation events on a per-instance exclusive queue.
let cache_queue = format!("canopy-rules.cache.{}", Uuid::now_v7());
let engine_for_cache = rules_engine.clone();
let my_instance_id = rules_engine.instance_id().to_owned();
let _cache_sub_handle = boot
.subscriber
.subscribe_exclusive(
&cache_queue,
&["rules.cache_invalidated"],
move |envelope: EventEnvelope| {
let engine = engine_for_cache.clone();
let my_id = my_instance_id.clone();
async move {
let from_self = envelope
.payload
.get("instance_id")
.and_then(|v| v.as_str())
.is_some_and(|id| id == my_id);
if from_self {
return Ok(());
}
info!("received cache invalidation event, reloading rule sets");
engine.reload_all().await?;
Ok(())
}
},
)
.await
.context("failed to start cache invalidation subscriber")?;
// Build Axum router.
let state = AppState {
db: boot.db.clone(),
auth: boot.auth,
};
let service_routes = api::routes(rules_engine);
let router = ApiServer::router(
state,
service_routes,
ServerOptions {
cors_origins: settings.cors_origins,
body_limit: settings.body_limit,
},
None,
)
.layer(axum::Extension(boot.mq_health));
ApiServer::serve(router, settings.port, shutdown_signal()).await?;
Ok(())
}
Notes on import behavior:
-
Idempotent: only imports rulesets whose name is not already in the database.
-
Uses the
namefield from the JSON content, falling back to the filename stem. -
Uses the
_commentfield as the description (all 11 Georgia stubs include this field). -
On second startup, zero rulesets are imported — they already exist.
Step 6: Tests
Files: services/canopy-rules/tests/rules_engine_test.rs
Tests use the existing stub pass-through rulesets from rulesets/georgia/. Each test gets a fresh database via the canopy test harness.
Test: evaluate pass-through ruleset
#[tokio::test]
async fn evaluate_pass_through_ruleset() {
let (db, _) = canopy_test::setup_db().await;
let engine = RulesEngine::new(db.clone(), None, "georgia")
.await
.expect("engine init");
// Load snap-eligibility.json stub into the engine.
let content: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string("../../rulesets/georgia/snap-eligibility.json").unwrap(),
)
.unwrap();
let name = content["name"].as_str().unwrap();
store::create_rule_set(db.inner(), name, None, &content)
.await
.unwrap();
let decision = RulesEngine::compile_rule_set(&content).unwrap();
engine.insert_decision(name.to_owned(), decision).await;
// Evaluate with empty input -- stub returns {"result": "pass"}.
let output = engine
.evaluate(name, None, None, serde_json::json!({}))
.await
.expect("evaluation should succeed");
assert_eq!(output["result"], "pass");
}
Test: evaluation recorded in audit trail
#[tokio::test]
async fn evaluation_recorded_in_audit_trail() {
let (db, _) = canopy_test::setup_db().await;
let engine = RulesEngine::new(db.clone(), None, "georgia")
.await
.expect("engine init");
// Load and evaluate a stub ruleset.
let content: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string("../../rulesets/georgia/snap-eligibility.json").unwrap(),
)
.unwrap();
let name = content["name"].as_str().unwrap();
store::create_rule_set(db.inner(), name, None, &content)
.await
.unwrap();
let decision = RulesEngine::compile_rule_set(&content).unwrap();
engine.insert_decision(name.to_owned(), decision).await;
engine
.evaluate(
name,
Some("application"),
Some(Uuid::now_v7()),
serde_json::json!({"gross_monthly_income": 1800}),
)
.await
.unwrap();
// Verify the evaluation was recorded.
let evals = store::list_evaluations_paged(
db.inner(), 10, 0, Some(name), None, None,
)
.await
.unwrap();
assert_eq!(evals.len(), 1);
assert_eq!(evals[0].rule_set_name, name);
assert!(evals[0].duration_ms >= 0);
assert_eq!(evals[0].context_type.as_deref(), Some("application"));
}
Test: CRUD rule set lifecycle
#[tokio::test]
async fn crud_rule_set_lifecycle() {
let (db, _) = canopy_test::setup_db().await;
let pool = db.inner();
// Create
let content = serde_json::json!({"nodes": [], "edges": []});
let created = store::create_rule_set(pool, "test-ruleset", Some("A test"), &content)
.await
.unwrap();
assert_eq!(created.name, "test-ruleset");
assert!(created.active);
// Get
let fetched = store::get_rule_set(pool, created.id).await.unwrap().unwrap();
assert_eq!(fetched.name, "test-ruleset");
// Update
let updated = store::update_rule_set(
pool,
created.id,
Some("renamed-ruleset"),
Some("Updated desc"),
None,
Some("v2.0"),
)
.await
.unwrap()
.unwrap();
assert_eq!(updated.name, "renamed-ruleset");
assert_eq!(updated.version, "v2.0");
// List -- should find the updated rule set.
let list = store::list_rule_sets_paged(pool, 10, 0, None, None, None)
.await
.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "renamed-ruleset");
// Soft-delete
let deleted_name = store::deactivate_rule_set(pool, created.id)
.await
.unwrap()
.unwrap();
assert_eq!(deleted_name, "renamed-ruleset");
// Verify gone from active list.
let list_after = store::list_rule_sets_paged(pool, 10, 0, None, None, None)
.await
.unwrap();
assert!(list_after.is_empty());
}
Test: import replaces content and bumps version
#[tokio::test]
async fn import_replaces_content_and_bumps_version() {
let (db, _) = canopy_test::setup_db().await;
let pool = db.inner();
// Create initial rule set.
let v1_content = serde_json::json!({"nodes": [], "edges": []});
let created = store::create_rule_set(pool, "import-test", None, &v1_content)
.await
.unwrap();
assert_eq!(created.version, "v1.0");
// Import new content with a new version (simulates the import handler).
let v2_content = serde_json::json!({"nodes": [{"id": "new"}], "edges": []});
let version = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
let updated = store::update_rule_set(
pool,
created.id,
None,
None,
Some(&v2_content),
Some(&version),
)
.await
.unwrap()
.unwrap();
assert_eq!(updated.version, version);
assert_eq!(updated.content["nodes"][0]["id"], "new");
}
Test: cache invalidation event published
#[tokio::test]
async fn cache_invalidation_event_published() {
let (db, _) = canopy_test::setup_db().await;
// Use a mock publisher that captures published events.
let (mock_pub, rx) = canopy_test::mock_publisher();
let engine = RulesEngine::new(db, Some(mock_pub), "georgia")
.await
.expect("engine init");
engine
.notify_cache_invalidated("snap-eligibility", "created")
.await;
// Verify the event was published.
let envelope = rx.try_recv().expect("should have received an event");
assert_eq!(envelope.event_type, "rules.cache_invalidated");
assert_eq!(
envelope.payload["rule_set_name"].as_str().unwrap(),
"snap-eligibility"
);
assert_eq!(
envelope.payload["action"].as_str().unwrap(),
"created"
);
assert_eq!(
envelope.payload["instance_id"].as_str().unwrap(),
engine.instance_id()
);
}
Files Touched
| File | Change |
|---|---|
|
New: rule_sets and rule_evaluations tables with indexes |
|
New: RulesEngine with zen-engine, dedicated thread, mpsc channel, duration tracking |
|
New: RuleSet/RuleEvaluation models, all CRUD + evaluation query functions |
|
Replaced: full CRUD + evaluate + import/export endpoints (was stub mod.rs) |
|
Implement rules.evaluated and rules.cache_invalidated publishers |
|
Wire engine, store, migration runner, ruleset import, cache invalidation subscriber |
|
New: 5 integration tests covering evaluate, audit trail, CRUD, import, cache events |
Verification
-
cargo nextest run -p canopy-rules— all 5 tests pass -
cargo xtask dev restart— migration runs, 11 Georgia rulesets imported from disk -
Manual:
POST /v1/evaluatewith snap-eligibility returns{"result": "pass"} -
Manual:
GET /v1/evaluationsshows the evaluation audit entry with duration_ms -
Manual:
POST /v1/rule-setsthenDELETE /v1/rule-sets/{id}— CRUD lifecycle works -
cargo clippy -p canopy-rules — -D warnings— clean
Documentation Updates
-
.claude/docs/services.md— add rules endpoint table, event list -
CHANGELOG.adoc— entry under== Unreleased -
.claude/CLAUDE.md— update canopy-rules feature status