Case Routing Rule Set
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Save plan document and link in nav.adoc |
Done (pre-ADR-030) |
2 |
Design rule set inputs, outputs, and decision logic |
Done (pre-ADR-030) |
3 |
Create Georgia case routing JDM file |
Done (pre-ADR-030) |
4 |
Create Texas case routing JDM file |
Done (pre-ADR-030) |
5 |
Add event-triggered evaluation |
Done (pre-ADR-030) |
6 |
Integration tests |
Done (pre-ADR-030) |
7 |
Verification and documentation updates |
Done (pre-ADR-030) |
Branch: feature/case-routing-ruleset
Context
When a referral is screened in and becomes an investigation, the system must route it to an appropriate caseworker and assignment area. Today this is a manual process — supervisors assign cases by hand. Automated routing reduces assignment delays, balances caseloads, and ensures geographic coverage.
CRAIG already has 5 JDM rule set domains per jurisdiction (intake-screening, safety-assessment, ive-eligibility, placement-matching, timeliness). Case routing is the 6th domain, completing the automation of the referral-to-assignment pipeline.
The existing intake-screening rule set determines whether a report is screened in and assigns a priority. The case routing rule set takes the output of intake screening (priority, allegation type) plus geographic and caseload data to determine who handles it.
Scope
In scope:
-
Georgia case routing JDM file (
rulesets/georgia/georgia-case-routing.json) -
Texas case routing JDM file (
rulesets/texas/texas-case-routing.json) -
Event-triggered evaluation via
case.referral_createdevent -
Integration tests for routing evaluation
-
Rule set documentation in the file’s
descriptionfield
Out of scope:
-
Caseload balancing API (requires real worker registry — use placeholder logic)
-
Supervisor override UI (web UI feature, separate plan)
-
Real-time reassignment when worker capacity changes
Design
Rule Set Architecture
The case routing rule set uses two chained decision tables (multi-node JDM graph):
-
Assignment Area Table — maps
admin_unit+allegation_typeto anassignment_area(geographic/functional grouping) -
Worker Selection Table — maps
assignment_area+priority+caseload_tierto arouting_decisionandassignment_method
This two-stage approach separates geographic routing from caseload-based selection, matching how real child welfare agencies organize (regional offices → unit assignments).
Inputs
| Field | Type | Description |
|---|---|---|
|
string |
County/administrative unit where the child is located (from referral) |
|
string |
Type of allegation: |
|
string |
Response priority from intake screening: Georgia uses |
|
string |
Current caseload level of available workers: |
|
boolean |
Whether Indian Child Welfare Act applies (requires specialized worker) |
|
string |
Special unit requirement: |
Outputs
| Field | Type | Description |
|---|---|---|
|
string |
Geographic/functional area for routing (e.g., |
|
string |
How to assign: |
|
string |
Selection method within the pool: |
|
number |
Maximum hours until first contact, based on priority |
|
string |
Plain-language explanation of why this routing was chosen |
Node Graph
[Input] → [Assignment Area Table] → [Worker Selection Table] → [Output]
Three nodes connected by two edges, following the same pattern as existing rulesets like georgia-intake-screening.json.
Georgia-Specific Rules (GA DFCS Policy Chapter 4)
Georgia DFCS organizes investigations by region (14 regions covering 159 counties). Assignment areas map to DFCS regional offices:
-
Metro Atlanta counties (Fulton, DeKalb, Gwinnett, Cobb, Clayton) →
metro_atlanta -
Coastal counties (Chatham, Glynn, Camden, etc.) →
coastal -
All other counties →
ruralwith sub-regions based on DFCS district
Special routing:
* Sexual abuse allegations → special_investigations unit regardless of geography
* ICWA-flagged cases → icwa_specialist queue
* Immediate priority + high caseload → supervisor_queue (requires manual assignment)
Texas-Specific Rules (DFPS Organizational Structure)
Texas DFPS uses 11 regions. Key differences from Georgia:
* Four priority tiers (P1 24hr, P2 72hr) vs Georgia’s three
* institutional_abuse_unit handles daycare/residential facility allegations
* Larger geographic regions — geographic_proximity assignment method used more often
Steps
Step 1: Save Plan and Link in nav.adoc
Files: docs/modules/ROOT/pages/plans/case-routing-ruleset.adoc, docs/modules/ROOT/nav.adoc
Create this plan file and add nav entry under Planned.
Step 2: Design Rule Tables
Files: None (design work)
Finalize the decision table rules:
Assignment Area Table (Georgia) — 12 rules:
| admin_unit pattern | allegation_type | assignment_area |
|---|---|---|
Fulton/DeKalb/Gwinnett/Cobb/Clayton |
any |
|
Chatham/Glynn/Camden/Bryan/Liberty |
any |
|
any |
|
|
(all other counties) |
any |
|
| Sexual abuse override must come before geographic rules (hitPolicy: first). This follows the same ordering principle as the intake-screening ruleset where specific rules precede catch-alls. |
Worker Selection Table (Georgia) — 8 rules:
| assignment_area | priority | caseload_tier | routing_decision | assignment_method |
|---|---|---|---|---|
|
any |
any |
|
|
any |
|
|
|
|
any |
|
any |
|
|
any |
|
|
|
|
any |
|
any |
|
|
any |
|
|
|
|
any |
|
any |
|
|
any |
any |
any |
|
|
Step 3: Create Georgia JDM File
Files: rulesets/georgia/georgia-case-routing.json
Follow the exact JDM structure from existing rulesets. Reference rulesets/georgia/georgia-intake-screening.json for the node/edge pattern:
{
"name": "georgia-case-routing",
"version": "v1.0",
"description": "Georgia DFCS case routing decision per Policy Chapter 4. Determines assignment area and routing method based on geography, allegation type, priority, and caseload. Uses two-stage decision: geographic/functional area assignment, then worker selection method.",
"nodes": [
{
"id": "input",
"name": "Input",
"type": "inputNode"
},
{
"id": "assignment-area",
"name": "Assignment Area Determination",
"type": "decisionTableNode",
"content": {
"hitPolicy": "first",
"inputs": [
{ "id": "i_admin", "name": "Administrative Unit", "field": "admin_unit" },
{ "id": "i_allege", "name": "Allegation Type", "field": "allegation_type" },
{ "id": "i_icwa", "name": "ICWA Flag", "field": "icwa_flag" },
{ "id": "i_special", "name": "Special Unit Needed", "field": "special_unit_needed" }
],
"outputs": [
{ "id": "o_area", "name": "Assignment Area", "field": "assignment_area" }
],
"rules": [...]
}
},
{
"id": "worker-selection",
"name": "Worker Selection Method",
"type": "decisionTableNode",
"content": {
"hitPolicy": "first",
"inputs": [
{ "id": "i_area", "name": "Assignment Area", "field": "assignment_area" },
{ "id": "i_priority", "name": "Priority", "field": "priority" },
{ "id": "i_caseload", "name": "Caseload Tier", "field": "caseload_tier" }
],
"outputs": [
{ "id": "o_decision", "name": "Routing Decision", "field": "routing_decision" },
{ "id": "o_method", "name": "Assignment Method", "field": "assignment_method" },
{ "id": "o_hours", "name": "Max Response Hours", "field": "max_response_hours" },
{ "id": "o_notes", "name": "Routing Notes", "field": "routing_notes" }
],
"rules": [...]
}
},
{
"id": "output",
"name": "Output",
"type": "outputNode"
}
],
"edges": [
{ "id": "e1", "sourceId": "input", "targetId": "assignment-area", "type": "edge" },
{ "id": "e2", "sourceId": "assignment-area", "targetId": "worker-selection", "type": "edge" },
{ "id": "e3", "sourceId": "worker-selection", "targetId": "output", "type": "edge" }
]
}
Populate the rules arrays with the decision tables from Step 2. Use the exact JDM value syntax:
* String comparisons: "\"sexual_abuse\"" (quoted string inside quoted value)
* Boolean: "== true", "== false"
* Wildcards: "" (empty string)
* Number outputs: 24 (bare number)
Rule count: ~12 rules in assignment-area table + ~8 rules in worker-selection table = ~20 rules total.
Step 4: Create Texas JDM File
Files: rulesets/texas/texas-case-routing.json
Same structure as Georgia with Texas-specific adjustments:
-
11 DFPS regions instead of Georgia’s county-to-area mapping
-
P1/P2priorities instead ofIMMEDIATE/PRIORITY_24HR/STANDARD -
institutional_abuse_unitas an additional special unit type -
max_response_hours: P1=24, P2=72 (vs Georgia’s IMMEDIATE=1, PRIORITY_24HR=24, STANDARD=72)
Rule count: ~10 assignment-area rules + ~10 worker-selection rules = ~20 rules total.
Step 5: Add Event-Triggered Evaluation
Files: services/craig-rules/src/main.rs
The rules service already handles domain events via handle_domain_event in main.rs. Add a new event trigger:
// In handle_domain_event match:
"case.referral_created" => {
// Existing: evaluates {jurisdiction}-intake-screening
// Add: also evaluate {jurisdiction}-case-routing with the referral data
let routing_input = json!({
"admin_unit": payload["admin_unit"],
"allegation_type": payload["concern_type"],
"priority": screening_result["priority"], // from intake-screening output
"caseload_tier": "medium", // placeholder — real caseload data requires worker registry
"icwa_flag": payload.get("icwa_flag").and_then(|v| v.as_bool()).unwrap_or(false),
"special_unit_needed": "none",
});
evaluate_and_publish(&engine, &publisher, &format!("{jurisdiction}-case-routing"), routing_input).await;
}
The caseload_tier input is hardcoded to "medium" as a placeholder. Real caseload balancing requires a worker registry service (out of scope). The rule set architecture supports it — when the registry exists, just query actual caseloads and pass the tier.
|
Alternatively, if chaining two evaluations on a single event is complex, add a new event type "intake.screening_completed" that the intake-screening evaluation publishes, and trigger case-routing from that event.
The existing /v1/rules/evaluate endpoint also works for ad-hoc evaluation:
curl -X POST http://localhost:8001/v1/rules/evaluate \
-H "Authorization: Bearer $TOKEN" \
-d '{"rule_set": "georgia-case-routing", "input": {"admin_unit": "Fulton", ...}}'
Step 6: Integration Tests
Files: services/craig-rules/tests/api/evaluate.rs
Add tests to the existing evaluation test file, following the pattern of existing rule evaluation tests:
// Test 1: georgia_case_routing_sexual_abuse_routes_to_special_investigations
// Input: admin_unit="Fulton", allegation_type="sexual_abuse", priority="STANDARD",
// caseload_tier="low", icwa_flag=false, special_unit_needed="none"
// Expected: assignment_area="special_investigations", routing_decision="specialty_unit",
// assignment_method="round_robin"
// Test 2: georgia_case_routing_immediate_high_caseload_goes_to_supervisor
// Input: admin_unit="Fulton", allegation_type="neglect", priority="IMMEDIATE",
// caseload_tier="high", icwa_flag=false, special_unit_needed="none"
// Expected: routing_decision="supervisor_queue", assignment_method="manual"
// Test 3: georgia_case_routing_standard_low_caseload_auto_assigns
// Input: admin_unit="Chatham", allegation_type="neglect", priority="STANDARD",
// caseload_tier="low", icwa_flag=false, special_unit_needed="none"
// Expected: assignment_area="coastal", routing_decision="auto_assign",
// assignment_method="round_robin"
// Test 4: georgia_case_routing_icwa_flag_routes_to_icwa_specialist
// Input: admin_unit="Fulton", allegation_type="neglect", priority="STANDARD",
// caseload_tier="low", icwa_flag=true, special_unit_needed="none"
// Expected: assignment_area="icwa_specialist"
// Test 5: texas_case_routing_p1_auto_assigns_lowest_caseload
// Input: admin_unit="Harris", allegation_type="physical_abuse", priority="P1",
// caseload_tier="medium", icwa_flag=false, special_unit_needed="none"
// Expected: routing_decision="auto_assign", assignment_method="lowest_caseload",
// max_response_hours=24
// Test 6: texas_case_routing_institutional_abuse_routes_to_special_unit
// Input: admin_unit="Dallas", allegation_type="physical_abuse", priority="P2",
// caseload_tier="low", icwa_flag=false, special_unit_needed="institutional_abuse_unit"
// Expected: routing_decision="specialty_unit"
Use the existing test pattern from evaluate.rs:
let harness = TestHarness::new().await.unwrap();
let client = harness.admin_rules_client().await.unwrap();
let resp = client
.post("/v1/rules/evaluate")
.json(&json!({
"rule_set": "georgia-case-routing",
"input": { ... }
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["result"]["assignment_area"], "special_investigations");
Step 7: Verification and Documentation Updates
-
cargo nextest run --workspace --lib— all unit tests pass -
cargo xtask dev reload— services pick up new rulesets -
cargo nextest run -p craig-rules— integration tests pass including new routing tests -
Manual evaluation via curl against
/v1/rules/evaluatefor both jurisdictions -
Verify rule ordering: sexual abuse routes to special investigations even for metro counties
Files Touched
| File | Change |
|---|---|
|
New file: Georgia case routing JDM rule set (~20 rules) |
|
New file: Texas case routing JDM rule set (~20 rules) |
|
Add |
|
Add 6 integration tests for routing evaluation |
|
This plan document |
Verification
-
cargo nextest run --workspace --lib— unit tests pass -
cargo xtask dev reload -
cargo nextest run -p craig-rules— all evaluation tests pass -
cargo xtask e2e— existing E2E tests still pass -
Manual verification:
curl POST /v1/rules/evaluatewith case routing inputs
Documentation Updates
-
.claude/docs/rulesets.md— add case-routing as 6th domain, document inputs/outputs -
.claude/docs/services.md— add event trigger for case-routing evaluation -
CHANGELOG.adoc— entry under== Unreleased -
.claude/CLAUDE.md— update "5 Ruleset Domains" count to 6