Rule Set Patterns and Domain Knowledge
On this page
- JDM Format (zen-engine)
- Rule Ordering Gotcha (hitPolicy: first)
- Service-caller rows in the authz fixtures (#1062 sandwich)
- 6 Ruleset Domains per Jurisdiction
- Key Differences Between Jurisdictions
- Lifecycle: history, promote, rollback, pinning (#1556 / D2)
- RabbitMQ Event-Triggered Evaluations
- Build note: the
patchtool is a build dependency - Ruleset Files
- Policy References in Rulesets
JDM Format (zen-engine)
-
Each ruleset is a JSON file with
nodes(inputNode, decisionTableNode, outputNode) andedges -
Decision tables use
hitPolicy: "first"— first matching rule wins, ORDER MATTERS -
Rule values:
"< 18","== true","\"string_value\""(strings double-quoted inside) -
Empty string
""= wildcard/any value -
Cross-field references work in some contexts (e.g.
"> deadline_days"in timeliness) -
Outputs from upstream nodes flow as inputs to downstream nodes via edges
Rule Ordering Gotcha (hitPolicy: first)
-
More specific rules MUST come before less specific catch-all rules
-
Age ranges:
>= 21must precede>= 18rules (learned from TX age eligibility bug)
Service-caller rows in the authz fixtures (#1062 sandwich)
The {jurisdiction}-authz-<resource> fixtures gate bare machine callers
(i_service, matched only when NO X-Craig-Actor was lifted — BFF calls
evaluate as the worker) with the sandwich: scoped allow rows for the
audited consumers only, then a service DENY row AHEAD of every worker row —
so a machine token carrying stray human roles can never fall through
(hitPolicy: first). Resources with no bare-service consumer carry the DENY
row alone. Adding a service capability = adding an explicit scoped row and bumping the
fixture version (authoring convention — versions stay human-meaningful).
Since #1556 the convergent seeder ALSO compares canonical content (jq -cS
both sides), so a same-version content edit converges instead of being
silently skipped; the version bump is no longer the convergence mechanism.
Posture + audit record: the ADR-024 §#1062 amendment.
#1060 assessed whether the rule_evaluation service create row could
narrow or disappear once the cases→rules evaluate relays forward
X-Craig-Actor: it STAYS. The craig-web-mediated flows (safety assessment,
person-match suggestions) now evaluate as the acting worker, but the
conversion auto-link consumer relays on a minted cases service bearer with
no actor by construction (there is no inbound request to lift one from), so
the create-only service row remains its gate. No fixture change; the row’s
_description already names the relay flows.
Service-identity scoping (#1107)
The placement fixtures' one scoped service READ row (craig-financial’s subsidy-eligibility S2S read, #1069 M2) is the first row to discriminate WHICH service is calling. Two inputs make it possible, both already part of the engine’s evaluation contract (no claims change was needed):
-
i_service_id—claims.service_id, theservice:<id>role suffix (azp fallback) the engine has emitted since ADR-028. Conditioning on it binds a row to one authenticated principal (i_service_id: "'craig-financial'"). -
i_projection—resource.attrs.projection, a handler-layered endpoint-shape discriminator (the subsidy-eligibility handler insertsprojection = 'subsidy_eligibility'into its check ref; no other placement handler sets it). Conditioning on it makes the grant ENDPOINT-shaped: the same principal’s bare bearer on the full placement GET falls to the DENY row — the data-minimized projection is the only placement surface a service can read.
Design choice recorded: the ruleset-input approach won over a code-level
endpoint allowlist because the restriction stays in the same auditable,
jurisdiction-swappable policy layer as every other authz decision, and
claims.service_id was already in the JDM input. The curated bundles
hardcode this deployment’s craig-financial client id — production
bundles replace it alongside the rest of the fixture content. Worker and
admin rows ignore both inputs (empty-string wildcards), so human access
is untouched.
Authoring gotcha (bit #786 in Texas): every rule row must carry the
table’s FULL input-id set — zen-engine treats a MISSING key as no-match,
not as a wildcard (an empty string "" is the wildcard). A row authored
without a jurisdiction’s extra columns (Texas adds
i_regional/i_supervises) silently never fires.
Second application (#1329, fixtures 1.2.0): the rule_evaluation
fixtures' bare-service create row is narrowed to
i_service_id: "'craig-cases'" — the cases→rules evaluate relay’s SYSTEM
leg (the conversion auto-link inbox consumer, RelayAuth::service) is the
only legitimate bare-service evaluator; BFF-mediated evaluations carry a
verified X-Craig-Actor and ride the worker rows instead (the ADR-028
§Amendments relay-attribution contract). Deliberately NOT narrowed: the
rule_set read/list service rows — they serve the fleet-wide authz
cache-warm every service performs with its own bare token, so a principal
IN-list there would need editing on every new service, a maintenance
hazard with no attribution stake (ruleset reads write no
rule_evaluations row).
6 Ruleset Domains per Jurisdiction
Each jurisdiction has these 6 rulesets with naming {jurisdiction}-{function}:
-
ive-eligibility: Title IV-E foster care eligibility (AFDC relatedness, judicial requirements)
-
safety-assessment: Child safety decision (present danger, impending danger, vulnerability, protective capacity)
-
timeliness: Milestone deadline tracking with warn/critical/overdue status
-
intake-screening: Screen-in/screen-out + priority + investigation track assignment
-
placement-matching: Level of care + placement type preference + sibling + proximity
-
person-match (ADR-019): Per-(report-entry, candidate-person) verdict —
AUTO/SUGGEST/REJECTfrom a feature vector (name_exact,name_similarity_score,dob_exact,dob_within_30,gender,phone_match). Drives the report-detail "Suggest matches" UX + the auto-link branch inconvert_report.
metadata block extension (ADR-019, generalizable pattern)
Person-match rulesets carry a top-level metadata object at the JDM document root that the cases-side orchestrator reads to dispatch behavior:
"metadata": {
"seed_sources": ["allegations", "case_household"],
"name_similarity_algorithm": "trigram_jaccard"
}
-
seed_sources: [String]— bounded enum of candidate-assembly sources the orchestrator should run before evaluating the JDM. Current allowlist:allegations(persons named on the report’s converted referral),case_household(household members of a caller-suppliedseed_case_id). Always implicitly augmented by apg_trgmname pre-filter capped at 25 candidates. -
name_similarity_algorithm: String— registry key for thecraig_matching::NameSimilarityimpl to inject into Layer 1’s signal compute. Current allowlist:trigram_jaccard.
Both keys are validated at pre-push time by cargo xtask validate (see xtask/src/cmd/rules.rs::validate_metadata) and re-validated at runtime by the orchestrator’s similarity registry (services/craig-cases/src/matching/similarity.rs::select) — defense in depth against a deployed ruleset out of sync with the binary.
This metadata extension is intended to generalize. Future per-jurisdiction-pluggable behaviors (e.g., screening-allowlists in #212/#213) should add their own bounded keys + xtask validators following this template.
JDM emits classification, Rust does scoring (ADR-019 Open questions)
zen-engine v0.54 truncates fractional decision-table number outputs to int. Person-match rulesets therefore emit confidence: "AUTO" | "SUGGEST" | "REJECT" + reasons: String only — the numeric score is computed in Rust by the orchestrator (services/craig-cases/src/matching/mod.rs::derive_score).
Key Differences Between Jurisdictions
Georgia DFCS vs Texas DFPS
| Domain | Georgia | Texas |
|---|---|---|
Safety outcomes |
Binary: SAFE/UNSAFE |
Three-tier: SAFE/CONDITIONALLY_SAFE/UNSAFE |
IV-E age limit |
18 (with school exception) |
21 (extended foster care with participation) |
AFDC income threshold |
$1,850/mo |
$566/mo (1996 freeze — among lowest nationally) |
FFP rate |
0.6736 |
0.6146 |
Intake priorities |
IMMEDIATE/PRIORITY_24HR/STANDARD |
P1 (24hr) / P2 (72hr) |
Investigation tracks |
CPS_INVESTIGATION/FAMILY_ASSESSMENT |
INVESTIGATION/ALTERNATIVE_RESPONSE |
Placement LOC tiers |
3 (basic/therapeutic/residential) |
4 (basic/moderate/specialized/intense) |
Timeliness milestones |
9 types |
12 types (incl. adversary hearing 14d, dismissal 365d, medical exam 3d) |
Protective capacity |
Single boolean |
3 dimensions (cognitive/affective/behavioral) |
Deprivation types |
5 |
6 (adds abandonment) |
Lifecycle: history, promote, rollback, pinning (#1556 / D2)
Every API-path mutation of a rule set writes a rule_set_snapshots row in
the SAME transaction — insert-only content history keyed by the cache CAS
token (rule_set_id, revision), opened by a migration-time baseline
snapshot per pre-existing row. Every evaluation stamps
rule_evaluations.rule_set_revision from the cache entry that ran, so
"which exact rules produced this determination" is a total join for
post-D2 rows (the free-text version string never identified content).
Promote is the blessed operator path for content rollout, a two-step:
-
POST /v1/rules/sets/{id}/promote/previewcompiles the candidate, replays the last-100 real evaluation inputs against live AND candidate on an isolated thread (never the shared eval queue), and persists
returns a CATEGORICAL diff report — verdict deltas, evaluation-id pointers, corpus provenance, per-input-key coverage; corpus inputs never ride the report (#1130 single copy). Requires RuleSet Update AND RuleEvaluation List. -
POST /v1/rules/sets/{id}/promote(the consent act) re-binds the candidate by canonical hash to the named preview and re-CASes the live revision against the preview’sfrom_revisioninside the flip transaction — 409promote-preview-staleon drift, preview never burned. A below-floor corpus (thin_corpus) refuses without the explicitacknowledged_thin_corpusconsent: an empty diff over an empty corpus is not evidence. The promote snapshot carries the report as its evidence;rules.ruleset_promotedrides the same transaction.
Traffic-split canary is REJECTED as policy, not engineering: eligibility is a legal determination under the rules in effect — similarly-situated children must never be adjudicated under different rules simultaneously. Shadow-replay + human-confirmed atomic flip is the mechanism.
Rollback (POST /v1/rules/sets/{id}/rollback) restores a snapshot’s
content as a NEW revision (monotone history; the counter never rewinds),
compile-revalidates (the engine may have moved since the snapshot’s
recorded engine_version), requires a reason, refuses soft-deleted sets
(recreate is the path), and stages rules.ruleset_rolled_back.
Pinning: POST /v1/rules/evaluate accepts an optional
pin {rule_set_id, revision} — a snapshot replay for dispute review,
keyed by the FULL token (never name+revision; rebinding). Pinned
evaluations are NON-OPERATIVE: gated like the evaluations LIST
(supervisor+), no rules.evaluated event staged, the audit row carries
pinned = true. Absent snapshot / no-longer-compiling content are typed
refusals BEFORE an evaluation id exists.
Operator tooling: cargo xtask rules-promote <file> --reason '…'
renders the preview report and prints the preview id; executing is a
DELIBERATE second invocation with --confirm-preview <id> — the
human-read report is the control, and there is no auto-confirm.
DELETE /v1/rules/sets/{id} requires ?reason= (recorded on the delete
snapshot). The report-only rule_set_snapshot_revision_gap invariant
flags out-of-protocol writes (revision bumps without snapshots).
RabbitMQ Event-Triggered Evaluations
Domain events trigger automatic rule set evaluation via handle_domain_event in main.rs:
-
case.intake_created→{jurisdiction}-intake-screening(payload{investigation_id, referral_id}; the evaluation’s context is the new investigation — #1054) -
placement.requested→{jurisdiction}-placement-matching
(The eligibility.submitted binding was retired by #1054 — it never had a
producer; {jurisdiction}-ive-eligibility remains invocable via
POST /v1/rules/evaluate.)
Build note: the patch tool is a build dependency
rquickjs-sys (transitive: zen-engine → rquickjs → rquickjs-core → rquickjs-sys)
needs the patch tool at build time. The Docker builder stage installs it
(apk add … patch in the root Dockerfile); CI builds through the same pinned
rust:1.97-alpine image. A bare host build needs patch on the PATH too — most
distros ship it.
Ruleset Files
The /rulesets/{jurisdiction}/ directories are the source of truth (the discovery
test picks up every file automatically, so counts drift). Both jurisdictions carry
the shared domains below plus case-routing and the {jurisdiction}-authz-*
fixture family; Texas defers person-match per ADR-019 Open questions. Georgia
additionally carries georgia-screening-policy.json and the non-JDM .toml
parameter files (dashboard, case_detail):
-
{jurisdiction}-safety-assessment.json— child safety decision -
{jurisdiction}-intake-screening.json— screen-in/screen-out + priority -
{jurisdiction}-ive-eligibility.json— Title IV-E foster care eligibility -
{jurisdiction}-placement-matching.json— level of care + placement type -
{jurisdiction}-timeliness.json— ASFA regulatory deadline tracking -
georgia-person-match.json— report-entry ↔ persons-table linking (ADR-019); Texas counterpart deferred
Policy References in Rulesets
-
Georgia: GA DFCS Policy 19.11 (safety), Chapter 3 (intake), Chapter 11 (placement)
-
Texas: TFC §262/263 (safety, timeliness), 42 USC §672 (IV-E eligibility), DFPS timeliness standards