ADR-024: Policy Engine on zen-engine + RMQ Cache Invalidation
On this page
Status
Accepted (2026-05-09). Implementation lands across Plan A Steps 4–7
(Multi-Jurisdictional Authorization plan).
Step 4 ships ZenAuthzEngine + RulesetCache skeleton; Step 5 wires
the subscribe_exclusive invalidation channel + TTL fallback.
Context
ADR-023 established the data-driven policy engine architecture. This ADR specifies the engine’s design: where policies live, how they’re evaluated, and how cache coherence works across replicas.
CRAIG already uses zen-engine (gorules JDM) for {jurisdiction}-screening-policy and similar rulesets. Reusing it for authz avoids inventing a new DSL + parser + evaluator. Per CLAUDE.md, subscribe_exclusive() is CRAIG’s existing pattern for cache invalidation across replicas.
Decision
-
AuthzEnginetrait in newcrates/craig-authzcrate. Public surface:check(claims, resource_ref, action)for single-row predicates;auto_scope_list(claims, resource_type, action, jurisdiction)returningListScopefor SQL-filter scoping on LIST endpoints. -
Engine wraps
zen-engine’s `DecisionEngine. Each(jurisdiction, resource_type)pair maps to a JDM ruleset with name{jurisdiction}-authz-{resource}stored in craig-rules' existingrule_setstable. -
Per-replica in-memory cache loaded at boot via bulk-fetch (
GET /v1/rules/sets?prefix={jurisdiction}-authz-). Cache miss at request time → fail-closed deny + audit event. -
Boot-load failure → service bails (analogous to platform-stab-2’s encryption-mode-required boot guard).
-
Cache invalidation: craig-rules publishes
ruleset.changedevents on its outbox when admin endpoints mutate policies. Consuming services bind exclusive auto-delete queues to a newcraig.cache_invalidationstopic exchange (viasubscribe_exclusive). Receiving the event invalidates the local cache entry; next request fetches fresh. -
TTL fallback (1-hour default; env-overridable via
CRAIG_<SVC>__AUTHZ_POLICY_TTL_SECONDS) catches missed invalidation events. Per-replica jitter avoids thundering herd at TTL boundary.
Consequences
-
No new DSL/parser/evaluator — reuses CRAIG’s house pattern
-
Real-time invalidation via RMQ; TTL fallback for partition recovery + missed events
-
Cross-service authz works without per-request HTTP hops (cache + denormalized ownership data per Plan A §D5)
-
Operational dashboards:
authz.cache_miss+authz.cache_refreshed+authz.access_deniedaudit events reachaudit_logvia the injected sink (ADR-050 / #908; access_denied since #1063, volume-gated by the full-dimension aggregate with suppressed-count reporting — ADR-050 §Amendment #1063)
Amendment — #786 (2026-07-18): honest error classes + narrowed bootstrap fallback
-
AuthzError::PolicyMissingis now source-attested:lazy_loaddistinguishes "theRulesetSourceitself returnedNone`" (→ `PolicyMissing, 403) from a refresh fault (fetch/compile failure → newAuthzError::PolicyLoad, 500) — a fault can no longer masquerade as missing policy on any path (check,auto_scope_list,resolve_field_permission). -
The trait gains two provided typed seams —
check_typed/auto_scope_list_typed— surfacingAuthzErrorso craig-rules' bootstrap fallback admits onPolicyMissingONLY (admin → any action; service caller → Read/List; every admit stages arules.authz_fallback_admitevent). The defaults report an opaque non-admitting deny; test doubles inherit them unchanged. -
Degraded boot keeps the REAL source:
boot_authz_engineno longer substitutes an emptyInMemoryRulesetSource(which falsely attested universal absence and could never self-heal) — it degrades toZenAuthzEngine::boot_degraded(empty cache, real source; self-heal via per-request lazy loads + the invalidation subscriber + the bounded post-boot warmup — the TTL task only re-fetches names already cached). Withauthz_require_full_coverage=truea double fetch failure refuses to boot, naming source availability as the remedy. -
craig-security’s admin fallbacks (
authz_check_or_admin_fallback/authz_list_or_admin_fallback) are retired outright: NO error class admits anyone, and a healthy engine’sDeniedlist verdict is authoritative even for admins. The seeded reference policies no longer wildcard service callers (rule_set → read/list; rule_evaluation → create only, the cases→rules evaluate relay).
Amendment — #1062 (2026-08-04): fleet-wide service-caller row audit (the sandwich)
The #786 tightening generalized. An audited inventory (every claim adversarially
verified against call sites) found the bare-service-token consumer surface is
exactly: craig-intake’s report pipeline (report create/read/update
report_attachment create/list/read + partner_api_key/partner_signer_key read)
and craig-financial’s reads (person, case, placement) — every other of the 52
resource types had a wildcard i_service allow row nothing consumed. Three
structural facts drive the posture:
-
claims.is_serviceis set at evaluation time only when NO actor was lifted (engine.rs — Plan E §9b), so the provisioned BFF never matches service rows; they exist solely for bare machine principals. -
Removing a service row is fail-closed (trailing default-deny → 403 / empty list page) — but removal alone still lets a machine token carrying stray human roles fall through to worker rows.
-
The #1083 subsidy leading-DENY closes that: with
hitPolicy: first, a service DENY row ahead of the worker rows is terminal for every bare machine token.
Decision (user-steered): the sandwich, fleet-wide — scoped allow rows for
verified consumers only, then a service DENY row ahead of all worker rows;
DENY-only where no consumer exists. Landed in four tranches on #1062
(T1 cases, T2 security, T3 placement/financial/exchange, T4
reporting/composition + the rules pair; fixtures v1.1.0, or v1.2.0 where
#1213 had already bumped placement/kinship_option), engine matrix in
crates/craig-authz/tests/default_rulesets.rs §#1062 + black-box pins in
each owning service’s tests/api/service_row_tightening.rs.
T4’s rule_set/rule_evaluation retrofit closed #786’s stray-roles
residual: #786 had scoped those service rows (rule_set read+list = the
fleet authz cache-warm; rule_evaluation create = the cases→rules
evaluate relay) but left them BELOW the worker rows, so a machine token
carrying stray human roles matched "Admin: full access" first. The rows
now sit at the table head with the DENY row behind them. T4 also fixed
a latent Texas defect its own pins exposed: SEVEN rows across the Texas
rules pair were authored WITHOUT the i_regional/i_supervises input
keys — the three #786 scoped service rows AND the four caseworker
rows — and zen-engine treats a missing key as no-match, so a Texas
deployment’s authz cache-warm, evaluate relay, AND caseworker access to
the rules pair were all silently denied (devstack/CI run georgia, so it
never surfaced). Every row in every fixture now carries its table’s
full key set (verified fleet-wide, 104 fixtures); the authoring gotcha
is recorded in rulesets § service-caller rows.
The tranches recorded a deployed-wiring convention split the engine
matrix cannot see: on ListScope::Denied, craig-security and
craig-exchange serve 403, craig-cases and craig-placement serve an empty
page, craig-financial is MIXED (payments/adjustments/rates: empty page;
claims and the subsidy pair: 403), craig-reporting is MIXED
(afcars/ncands: 403; the data-quality-issue list: empty page —
quality_review itself is check-side only), and craig-composition has no
scoped lists (check-side only) — all fail-closed; the black-box pins
assert each service’s own convention.
Related finding: the cases→rules relay drops the actor header (worker
attribution lost at the hop) — #1329.
Amendment — #908 (2026-07-18): audit-event emission via injected sink (ADR-050)
-
The engine now REQUIRES an
Arc<dyn AuthzAuditSink>at construction (boot/boot_degraded); craig-bootstrap’sOutboxAuditSinkstages each envelope on the host service’s transactional outbox in a one-shot tx, fire-and-forget.authz.cache_missemits on every source-attested miss (never onPolicyLoadfaults) with acting-worker attribution;authz.cache_refreshedemits from every refresh path with trigger attribution (rmq_event/ttl_refresh/boot/warmup/lazy_load). Full design + alternatives: ADR-050.
Amendment — #1126 (2026-07-25): eval budget, negative absence cache, compile outside the lock
The 2026-07-25 performance pass (epic &71) found the engine’s three load amplifiers and fixed each:
-
Evaluation budget. Every decision funnels through ONE serial
!Sendzen eval thread per service; a slow ruleset head-of-line blocked every queued caller indefinitely.evaluatenow bounds the whole round-trip (channel send + queue-wait + eval) with a configurable budget (CRAIG_<SERVICE>__AUTHZ_EVAL_TIMEOUT_MS, default 5000 — the craig-rules #784 pattern). Elapse = typed fail-closedEvalTimeout→ 503 (never a 403 that would read as a deny, never an unbounded hang). Eval-thread pooling remains a possible follow-up. -
Negative absence cache. A source-attested-absent ruleset was re-fetched over HTTP and re-staged an
authz.cache_missaudit event on EVERY request. Absences now cache for a short TTL (30s default): repeat lookups answer locally and the audit event stages once per TTL window. Bounded by construction (ruleset names derive from the closed jurisdiction xResourceTypespace); a fresh seed becomes visible within one TTL even if theruleset.changedevent is missed. -
Compile outside the lock.
refresh_entryparsed + compiled JDM under the cache write guard (blocking all readers per refresh), andbulk_refreshheld it across ~50 compiles. Compilation now happens before the guard; the lock holds only for map mutation.
Amendment — #1328 (2026-08-09): fleet-owned JS bound + the authz eval-thread wedge deadman
The #1046 wedge posture craig-rules built for its zen-eval thread is
mirrored onto the embedded authz eval thread every service runs (the
authz-eval sentinel), closing the two gaps ADR-006 §Amendment #1046
named for the fleet:
-
The JS bound is owned at boot, fleet-wide. zen’s
function_timeout_millisis ONE process-global atomic (default 5 s, compiled in) sampled by every evaluation in the process; before this amendment only craig-rules' process set it. Every service now publishesCRAIG_<SERVICE>AUTHZ_FUNCTION_TIMEOUT_MS(defaultmin(5000, authz_eval_timeout_ms); explicit values validated1..=authz_eval_timeout_msat settings load — a JS node outliving the eval budget turns a clean typed runtime error into the #1126EvalTimeout503) throughboot_authz_engineintocraig_authz::apply_function_timeout. Single-owner-per-process rule: craig-rules passesNonethrough that seam — itsCRAIG_RULESFUNCTION_TIMEOUT_MS(#1046, applied earlier in its boot) owns the one atomic in that process, and the shared step must never overwrite it (last-writer-wins). Process inventory: craig-rules runs TWO zen engines (RulesEngine + its embedded authz engine) under its one bound; the other seven stateful services run exactly one authz engine each; craig-web / craig-intake / craig-intake-keyring link no zen at all. -
The wedge deadman, extracted and applied. The #1046 primitive (in-flight stamp for exactly the zen-future window — queue wait never stamps; a watchdog sampling every 5 s; threshold
max(10 × eval budget, 60 s); fire-once; no in-process respawn) now lives incraig_common::eval_deadman— extraction over clone-twinning because the deadman is a supervision primitive (the #1229 precedent put the supervisor registry in craig-common) and a twin would either trip the B8 duplicate-body budget or diverge to evade it. The authz eval loop stamps the RULESET NAME as its diagnostic (rules stamps therule_evaluationsaudit id);ZenAuthzEngine::eval_thread_endedwidens from death-only (#1228, previously documented only in craig-bootstrap + ADR-061) to death-OR-proven-wedge via the shareddeath_or_wedgefuture, feeding the SAMEauthz-evalCritical registration — a wedge now rides ADR-061 fail-fast (supervisor shutdown → nonzero exit → orchestrator restart). Design rationale
rejected alternatives (in-process respawn, N-thread pool, consecutive-timeout proxy): ADR-006 §Amendment #1046, unchanged. -
JS boundedness pinned, write-path refusal rejected. Authz rulesets today are table/expression-based (no
functionNodein any fixture), and the ruleset write path is deliberately a compile-only shape check — a name-convention-scoped functionNode refusal would be fragile and add nothing once the bound is owned: a JS node in an authz ruleset is legal-but-bounded by construction. Pinned byv2_function_node_is_interrupted_at_the_published_authz_bound(a 250 ms published bound interrupts a live spin, thread survives) and the end-to-end wedge proofwedge_deadman_fires_during_a_js_spin_while_the_thread_lives.