Rule Set Patterns and Domain Knowledge

On this page

JDM Format (zen-engine)

  • Each ruleset is a JSON file with nodes (inputNode, decisionTableNode, outputNode) and edges

  • 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: >= 21 must precede >= 18 rules (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_idclaims.service_id, the service:<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_projectionresource.attrs.projection, a handler-layered endpoint-shape discriminator (the subsidy-eligibility handler inserts projection = '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}:

  1. ive-eligibility: Title IV-E foster care eligibility (AFDC relatedness, judicial requirements)

  2. safety-assessment: Child safety decision (present danger, impending danger, vulnerability, protective capacity)

  3. timeliness: Milestone deadline tracking with warn/critical/overdue status

  4. intake-screening: Screen-in/screen-out + priority + investigation track assignment

  5. placement-matching: Level of care + placement type preference + sibling + proximity

  6. person-match (ADR-019): Per-(report-entry, candidate-person) verdict — AUTO/SUGGEST/REJECT from 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 in convert_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-supplied seed_case_id). Always implicitly augmented by a pg_trgm name pre-filter capped at 25 candidates.

  • name_similarity_algorithm: String — registry key for the craig_matching::NameSimilarity impl 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:

  1. POST /v1/rules/sets/{id}/promote/preview compiles 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.

  2. 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’s from_revision inside the flip transaction — 409 promote-preview-stale on drift, preview never burned. A below-floor corpus (thin_corpus) refuses without the explicit acknowledged_thin_corpus consent: an empty diff over an empty corpus is not evidence. The promote snapshot carries the report as its evidence; rules.ruleset_promoted rides 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

Edit this page · latest