Plan: Rules Evaluation Timeout (#784)
On this page
- Status
- Context
- Scope
- Design
- D1 — One budget encloses send + reply
- D2 — 503 via the existing
ApiError::service_unavailable - D3 — MQ path: propagate and disclose the REAL semantics
- D4 — Audit contract amended, not silently violated
- D5 — Dead-work skip: best-effort, with an honest test seam
- D6 — Tie semantics: reply wins at the deadline
- D7 — Config: pure typed parse helper + post-bootstrap env read (no wrapper struct)
- D8 — Range 1..=25_000 ms, default 5_000, honestly worded
- Steps
- Files Touched
- Verification
- Delivery
Status
| Step | Description | Status |
|---|---|---|
0 |
Commit this plan + nav link as the branch’s first commit; apply the scoped plan label to #784 |
Done (2026-07-17) — eb6b418f; Plan::Rules-Eval-Timeout applied |
1 |
Budget config: pure typed parse helper + post-bootstrap env read in main.rs (no |
Done (2026-07-17) — d626ec0c (config.rs: typed error, example tests + 3 proptests) |
2 |
Engine: |
Done (2026-07-17) — bc85afff (red, kill @120.003s recorded) + d626ec0c (green) |
3 |
API: 503 mapping via extracted |
Done (2026-07-17) — mapping + annotation in d626ec0c; page regenerated from the branch’s own |
4 |
Tests: paused-time suite + real-loop + cross-thread + mapping + config proptests (all inline — bin crate) |
Done (2026-07-17) — d626ec0c; 108/108 crate tests green (test 6 adapted: runtime-failing expressionNode graph — the echo fixture coerces null input to {}, so the #785 path was not reachable; strengthening recorded) |
5 |
Contracts & docs: ADR-006 (both sites) + implementation-guide, CHANGELOG entry, config/deploy/testing docs |
Done (2026-07-17) — all six surfaces in the Step 5 doc commit; |
6 |
File 7 follow-up issues (full quality bar) + reconcile #876/#783/#778/#971 + bump #1046 |
Done (2026-07-17) — filed #1048–#1054 (+ #1055, an 8th from the Step-5 J-review: the api-docs generator’s In/Required defect), all `/relate`d; #876 amended to shipped reality; #783 AC 1 checked + related to #1046; #1046 bumped to P2-medium with the severity note |
Issues: #784
Branch: feature/784-rules-eval-timeout
MR: !1004 (2026-07-17; archived in-MR once the IID was known)
Provenance: 5 contextless review rounds (3 internal-lens rounds + 1 external + 1 confirmation; findings 40 → ~35 → 19 → 11 → polish-only, all folded in). User-decided forks: ADR-006 amended + failure-audit follow-up (not designed here); the setting is rules-local.
Context
run_eval (services/craig-rules/src/engine.rs:208) sends an EvalRequest to the single-threaded zen-engine eval thread and awaits the reply unbounded. Two callers share it: the HTTP evaluate endpoint (authz-gated via rules_authz_or_admin_fallback; caller-supplied JSON, non-admin-reachable) and the RabbitMQ domain-event subscriber (main.rs:318-332). One slow evaluation hangs its caller; a wedged one eventually blocks all callers (256-slot queue). #911 made the lying comments honest; engine.rs:26 and :205-207 defer to #784 — this plan implements the bound they promise.
Severity above the original "medium": arbitrary caller JSON can wedge the sole eval thread, and a running zen evaluation cannot be cancelled — real recovery is #1046 (priority bumped in Step 6).
Scope
In: rules-local budget config · one budget over send+reply · EvalTimeout → 503 · best-effort dead-work skip · truthful MQ/audit/docs contracts · full test suite · utoipa + api-page truth-up.
Out (every item tracked in Step 6 or already open):
-
Wedge recovery → #1046 (priority bump — done, P2-medium)
-
craig-authz mirrored loop (must fail closed) → #1047
-
Failure/attempt audit rows (user-deferred; the gap predates this plan) → #1048
-
application/problem+jsonmedia-type debt → #1049 -
configuration-reference page-wide audit → #1050
-
cases-side 503 pass-through → #1051
-
CHANGELOG NUL-byte fix (separate chore — found-mid-implementation rule; the byte is load-bearing inside a backticked literal at
CHANGELOG.adoc:1796and needs a printable escape, not deletion) → #1052 -
MQ subscriber/inbox retry discrepancy (pre-existing: subscriber DLXes on 2nd delivery; inbox’s 5-retry path unreachable;
failed_atnever stamped) → #1053 -
case.intake_createdpayload-key mismatch + theeligibility.submitteddead-from-birth subscription (pre-existing;placement.requestedis already #971’s) → #1054 -
api-docs generator In/Required rendering defect (found by the Step-5 J-review) → #1055
Design
D1 — One budget encloses send + reply
Capacity bounds queue depth, not wait time; a reply-only bound leaves senders hanging on a wedged thread + full queue. tokio mpsc send is cancel-safe: timing out mid-send drops the request unqueued (pinned by test 2’s drain assertion).
D2 — 503 via the existing ApiError::service_unavailable
craig-rules is the origin server; zen is an internal worker — 504 was semantically wrong. Reuse costs nothing new (variant, constant, 503 test all exist) and the wire arm echoes the detail we pass: the fixed PII-free rules evaluation timed out after {budget_ms} ms. Recorded residuals: (a) clients discriminate this 503 from others only by detail string — if a client ever needs to branch, service_unavailable_typed is a small precedented addition (the #311 sub-typing pattern); (b) until the cases pass-through follow-up lands, the two s2s consumers fold this into their own redacted 500 — the 503 contract holds for direct callers (CLI, curl) today. No Retry-After (no truthful estimate; no consumer auto-retries today).
D3 — MQ path: propagate and disclose the REAL semantics
The subscriber’s actual discipline (verified): first handler Err → nack requeue=true; the redelivered attempt → nack requeue=false → broker DLX on the 2nd failure, the two failures ~budget+3-5s apart (inbox Path C sleeps backoff_for_attempt(1) = 4s ±25% before the re-run). The inbox’s 5-retry/backoff path is unreachable on this route and failed_at is never stamped (pre-existing discrepancy → Step 6 issue, related to open #778 whose premise it corrects). Nothing replays the DLX (craig-security’s DLQ consumer is forensic capture + alerting only).
Accepted because today’s blast radius is ~zero, with evidence: of the three subscribed events, placement.requested has no producer (tracked in open 971) and eligibility.submitted has never had one (dead from birth — no emission commit in full history), while case.intake_created’s evaluation is advisory today: `rule_evaluations has no readers outside craig-rules; rules.evaluated feeds a craig-financial no-op logger (open #245 would make it functional — a timestamped fact, not a permanent one) and craig-security’s wildcard () audit subscriber, so a timed-out MQ eval also leaves no security-audit row (same class as the D4 gap; stated in the ADR amendment). The intake_created handler also reads a payload key the producer never sends (case_id vs investigation_id/referral_id → context_id always None; pre-existing, filed Step 6). No separate MQ budget (YAGNI on dead/advisory paths). CHANGELOG discloses: domain-event evaluations that exceed the budget dead-letter after ~2 attempts.
D4 — Audit contract amended, not silently violated
ADR-006’s "every evaluation is recorded" is already false for zen runtime errors (? before the audit write). Amend BOTH ADR sites (adr-006-rules-engine.adoc:30 and :36) plus implementation-guide.adoc:1054 to completed-evaluation wording with the failure-class gap explicit (including timed-out-then-late-completing evals being discarded unaudited). Failure-audit rows for all classes → Step 6 follow-up (user-decided).
D5 — Dead-work skip: best-effort, with an honest test seam
A guard at the loop top skips requests whose caller gave up — racy by nature (post-check cancellation happens; running evals untouchable). Seam: eval_loop becomes a thin wrapper over eval_loop_inner(rx, evals_run: &AtomicUsize) — the counter param IS the test oracle (no cfg(test) production code beyond one delegation line; each test owns a private counter; safe under any runner). The guard’s else-path reply send keeps the existing // SILENT-OK: marker semantics.
D6 — Tie semantics: reply wins at the deadline
tokio Timeout polls the inner future first; a reply ready in the same tick as the deadline succeeds. Documented on dispatch_eval, pinned by test 4. Corollary (verified): no await sits between send returning and the dispatched = true assignment, so the flag is reliable; its meaning is "queued, but no reply within budget" — NOT a wedge diagnosis (deep queue ≠ wedged thread; the doc says so).
D7 — Config: pure typed parse helper + post-bootstrap env read (no wrapper struct)
Review round 3 killed a wrapper-struct design: craig-rules boots via craig_api::bootstrap("CRAIG_RULES", …) which runs dotenvy and loads ServiceSettings internally — a wrapper double-loads or dead-codes, and a pre-bootstrap load misses dotenvy. Rules-local instead means: services/craig-rules/src/config.rs exposes parse_eval_timeout_ms(raw: Option<&str>) → Result<u64, EvalTimeoutConfigError> (pure, typed error, unit- and property-tested, no env) and eval_timeout_ms_from_env() (2-line wrapper reading CRAIG_RULES__EVAL_TIMEOUT_MS), called in main.rs after bootstrap(…) returns. Nothing shared changes.
D8 — Range 1..=25_000 ms, default 5_000, honestly worded
Default justified by the seeded corpus (largest ruleset: 9 nodes/19 edges — single-digit-ms evals; ~3 orders of magnitude headroom) while keeping a timeout storm’s per-request cost short. Cap rationale, correctly scoped: bounds wedge exposure everywhere, and for HTTP callers stays under the shared 30s client budget with headroom for authz + audit + network (crates/craig-common/src/http.rs:20); the MQ path has no HTTP budget, so the cap there is purely wedge-exposure bounding. Success latency ≈ budget + audit write (the audit tx runs after the dispatch, bounded separately by db_statement_timeout_ms) — stated in the config docs.
Steps
Step 0 — Commit the plan first
This file + the nav Active entry are the branch’s first commit (docs(plan): commit the #784 rules-eval-timeout plan). Apply the scoped Plan::Rules-Eval-Timeout label to #784 (auto-drops Plan::NEEDED).
Step 1 — Budget config (services/craig-rules/src/config.rs, new)
/// Why parsing failed. Typed per the errors-are-typed rule; Display
/// carries the operator-facing D8 message.
#[derive(Debug, thiserror::Error)]
pub(crate) enum EvalTimeoutConfigError {
#[error("CRAIG_RULES__EVAL_TIMEOUT_MS must be in 1..=25000 (got {got}): values above 25s leave no headroom under HTTP callers' shared 30s budget and extend wedge exposure")]
OutOfRange { got: u64 },
#[error("CRAIG_RULES__EVAL_TIMEOUT_MS must be a positive integer (milliseconds), got {raw:?}")]
NotANumber { raw: String },
}
/// Parse the eval-dispatch budget from its env string. Pure so the
/// range contract is unit- and property-testable without env mutation.
pub(crate) fn parse_eval_timeout_ms(raw: Option<&str>) -> Result<u64, EvalTimeoutConfigError>
-
None→ 5_000.Some→ parse u64, then range 1..=25_000. -
pub(crate) fn eval_timeout_ms_from_env() → Result<u64, EvalTimeoutConfigError>readsCRAIG_RULES__EVAL_TIMEOUT_MSand delegates. -
main.rscalls it afterbootstrap(…), propagates the Err through main’s existinganyhowcontext, passes the value intoRulesEngine::new, and `info!`s the budget (the live-verification hook). -
Tests (inline mod, pure fn — no env, no unsafe): None-default; valid value;
0,25_001,"abc"rejected viamatches!+ Display-message assertions. Plus the mandatory proptests (parser, universally-quantified invariants): ∀ v in 1..=25_000 →Ok(v); ∀ u64 outside →OutOfRange; ∀ non-numeric string →NotANumber, never a panic.
Step 2 — Engine (services/craig-rules/src/engine.rs)
-
Variant:
/// The eval dispatch missed its budget. `dispatched: false` means /// send never completed (queue full); `true` means queued but no /// reply within budget — deep queue OR slow/wedged evaluation, the /// flag does not distinguish. The evaluation is NOT cancelled /// (#1046); the loop skips dead requests best-effort. #[error("rules evaluation of '{rule_set}' exceeded the {budget_ms} ms budget (dispatched: {dispatched})")] EvalTimeout { rule_set: String, budget_ms: u64, dispatched: bool }, -
RulesEngine.eval_budget_ms: u64, threaded through the single constructor (engine.rs:97) frommain.rs:95. -
Free fn (the engine is not unit-constructible — channel/thread/DB in
new()):async fn dispatch_eval( eval_tx: &mpsc::Sender<EvalRequest>, rule_set: &str, decision: Arc<ZenDecision>, // STRUCTURAL-VALUE: JDM rule content — zen-engine polymorphic input: serde_json::Value, budget_ms: u64, // STRUCTURAL-VALUE: JDM rule content — zen-engine polymorphic ) -> Result<serde_json::Value, EngineError>One
tokio::time::timeout(Duration::from_millis(budget_ms), fut)— ms stayu64; the sole conversion is at the timer boundary.fut: send (Err →WorkerUnavailable), set localdispatched = true, reply await (Err →WorkerUnavailable), inner Err →Evaluation. Elapsed →EvalTimeout { rule_set, budget_ms, dispatched }. The STRUCTURAL-VALUE markers are load-bearing (B3a budget gate). Doc records the D6 tie semantics. -
run_evalgainsrule_set: &str(its one caller has it) and delegates. -
eval_loop→ thin wrapper overeval_loop_inner(rx, evals_run: &AtomicUsize); the inner loop top gains:// Best-effort: skip requests whose caller already gave up. Racy by // design — post-check cancellation still evaluates for a dead // oneshot (SILENT-OK send below), and a RUNNING eval is // uncancellable (#1046). if req.reply.is_closed() { continue; } -
Truth up
engine.rs:26and:205-207.
Step 3 — API mapping + page truth-up
-
services/craig-rules/src/api.rs(~:774) — extract the whole EngineError→ApiError closure into a named fnfn engine_error_to_api(e: EngineError) → ApiError(the handler’s.map_errcalls it) so the mapping is testable. New arm, destructured (nounreachable!, no wildcard — both workspace-denied):crate::engine::EngineError::EvalTimeout { rule_set, budget_ms, dispatched } => { tracing::warn!(%rule_set, budget_ms, dispatched, "rules evaluation timed out"); ApiError::service_unavailable(format!( "rules evaluation timed out after {budget_ms} ms" )) }New test in api.rs’s existing inline
#[cfg(test)]module:eval_timeout_maps_to_service_unavailable_with_budget_detail— assertsApiError::ServiceUnavailablewith the exact detail string (a mis-mapping into theinternalarm would otherwise pass the whole suite). -
The evaluate
#[utoipa::path]currently declares only 200/401/404 (api.rs:741-745) — the regen reflects annotations, not runtime, so amend the annotation to the real set: 200/401/403/404/500/503 (allProblemDetails-bodied like adjacent entries). -
Regen ONLY this page:
cargo xtask api-docs --service craig-rulesagainst the branch-built devstack (output is deterministic: sorted paths/codes, no timestamps/ports); discard any diff to other services' pages (that sweep is #1022’s single-docs-MR mandate — the MR carriesRelates to #1022); post-regen, assert the page contains the 503 row (guards against a stale running image).
Step 4 — Tests
All inline #[cfg(test)] in engine.rs/config.rs/api.rs — craig-rules is a bin-only crate; tests/ integration files cannot reach pub(crate) internals. Dev-deps: tokio = { workspace = true, features = ["test-util"] } + proptest = { workspace = true } (neither present today; craig-test-lib re-exports neither).
Harness facts: spawn the dispatch (tokio::spawn + async move with a cloned sender); yield_now().await before tokio::time::advance; matches! assertions (EngineError: !PartialEq); import Duration; requests built from the minimal JDM fixture (crates/craig-test-lib/src/builders.rs:40) through the pub compile_rule_set(&Value) — an empty graph is invalid JDM, so use the fixture. Seam-drive mechanics (the one 'static trap): a test-local AtomicUsize cannot go through spawn_local — test 7 drives loop and client concurrently in ONE task (tokio::join! inside LocalSet::run_until, dropping the sender to end the loop); test 8 moves an Arc<AtomicUsize> clone into the std::thread and passes &*arc inside. Production eval_loop wraps eval_loop_inner(rx, &local_counter) in its own future — no 'static needed; the rt.block_on topology at engine.rs:117 is unchanged.
Paused-time (#[tokio::test(start_paused = true)]):
-
eval_timeout_fires_at_budget_when_reply_never_arrives— drain request, hold oneshot; advance(budget);EvalTimeout { dispatched: true, .. }, exact budget + rule set. -
eval_timeout_on_full_queue_reports_undispatched— capacity-1 pre-filled, receiver silent;EvalTimeout { dispatched: false, .. }; then drain and assert only the pre-fill was queued (cancel-safety). -
eval_reply_within_budget_is_unaffected. -
reply_ready_at_exact_deadline_wins(pins D6). -
worker_gone_maps_to_worker_unavailable— send-err (receiver dropped) and reply-err (oneshot sender dropped post-drain) halves.
Real-loop (current-thread + LocalSet; zen futures are !Send):
-
zen_error_maps_to_evaluation— invalid input through the real loop. -
eval_loop_skips_requests_whose_caller_gave_up— two requests (dead first, live second) througheval_loop_innerwith a private counter; assert counter == 1 AND live answered. Real oracle — fails without the guard. -
Cross-thread variant of 7: real
std::thread+ its own single-thread runtime (production topology), plain#[tokio::test](no paused clock across threads).
API mapping (api.rs inline module):
-
eval_timeout_maps_to_service_unavailable_with_budget_detail— pins the extractedengine_error_to_apiper Step 3.1.
Config (config.rs inline module): the example + property tests per Step 1.
Dropped, deliberately:
-
Reply-send-failure-after-guard test — the only reachable window is the check-then-cancel race, not deterministically constructible; the absorption is the pre-existing SILENT-OK contract, unchanged here.
-
MQ-propagation unit test —
handle_domain_eventtakes a concreteRulesEngine(not unit-constructible) and the coupling is a single?atmain.rs:332; the subscriber/inbox machinery has its own suites; the MQ path’s blast radius today is ~zero (D3). The dead-subscription follow-up owns any revisit.
Red-before-green: the first implementation commit extracts dispatch_eval WITHOUT the timeout; test 1 → nextest slow-timeout kill (.config/nextest.toml:9) proves the defect; the next commit adds the timeout; both runs recorded in the MR.
Step 5 — Contracts & docs
-
ADR-006 amendment at both claim sites (
:30,:36) +implementation-guide.adoc:1054, per D4. -
CHANGELOG.adoc: issue-scoped entry (match the existing heading shape) disclosing the HTTP 503 AND the MQ effect in D3’s honest form. (NUL byte: separate chore issue — not this MR.) -
configuration-reference.adoc: the env-var row (name, default 5000, range, D8 wording). Page-wide audit → Step 6. -
Deployment guide +
.env.example+ devstack compose passthrough (CRAIG_RULESEVAL_TIMEOUT_MS: ${CRAIG_RULESEVAL_TIMEOUT_MS:-5000}, per the existing optional-var pattern). -
testing-reference.adoc: paused-time/test-utiltechnique note + the counter-seam oracle pattern. -
.claude/CLAUDE.md: explicitly N/A (no status-table surface for a weight-3 fix) — recorded so the checklist item is addressed, not skipped.
Step 6 — Follow-ups + open-tracker reconciliation
New issues (each to the full bar: what+why, testable AC, type+priority labels, weight, /relate #784):
-
feat(rules): failure/attempt audit rows for errored evaluations (all classes)— from D4; note the craig-security wildcard-audit gap too. -
fix(common): ApiError emits application/json; architecture documents application/problem+json. -
docs: configuration-reference page audit (stale counts/defaults, naming). -
fix(cases): pass upstream rules 503/timeout through instead of folding into 500. -
chore: replace the raw NUL byte in CHANGELOG.adoc:1796 with a printable escape(load-bearing literal; docs-only). -
fix(mq): subscriber dead-letters on 2nd delivery — inbox 5-retry/backoff path unreachable, failed_at never stamped—/relate #778. -
fix(rules): case.intake_created handler reads case_id but the producer emits investigation_id/referral_id (context_id always None)— include the stalereporting/main.rs:409comment + theeligibility.submitteddead-from-birth subscription;/relate #971(which ownsplacement.requested— do NOT re-file that half).
Open-tracker reconciliation:
-
#876 — comment + amend its AC to shipped reality (503 via ServiceUnavailable; inline tests); its coerced-Null audit half stays open.
-
#783 — comment that AC 1 is satisfied by this MR, check the box,
/relate #1046for the recovery track. -
Bump #1046 to P2 with the severity note (caseworker-reachable arbitrary JSON, uncancellable eval, DLX has no replay).
Files Touched
| File | Change |
|---|---|
|
typed parse fn + env wrapper + tests + proptests |
|
post-bootstrap read, propagate Err, thread budget, boot |
|
variant, budget field, |
|
extracted |
|
dev-deps: tokio |
|
regenerated ( |
|
audit-contract amendment |
|
this Step 0; at completion: file → |
|
Step 5 |
|
issue-scoped entry only (NUL untouched here) |
Not touched: crates/craig-common/**, crates/craig-api/bootstrap.rs, shared-crates.adoc, architecture.adoc, services.adoc (thin index — no settings surface), .claude/CLAUDE.md (N/A per Step 5.6).
Verification
-
cargo nextest run -p craig-rules— suite green; red-before-green for test 1 recorded. -
cargo clippy --workspace --all-targets --locked — -D warnings. -
Deterministic live check: devstack up on the branch build; craig-rules boot log shows the compose-provided budget; the regenerated api page contains the 503 row. (A 1 ms 503 probe is optional/probabilistic — the deterministic evidence is the tests + wiring checks.)
-
Full pre-push battery (B3a/B4 locks stay LOCKED).
-
Grep sweeps: no
#784deferral comments remain; both ADR-006 sites + implementation-guide amended; the api page’s response set matches the annotation.
Delivery
Commit order: Step 0 plan → red-before-green pair → remaining steps. Every commit runs the full pre-commit token gate AND the J1–J8 subagent pass — no carve-out applies. The fix commit is fix(rules): implement the evaluate() dispatch timeout (#784) (d626ec0c, Relates to #784 — trailing doc/true-up commits follow it); the Closes #784 rides the branch’s final commit and the MR description. Battery push → MR (template body; Relates to #1022; MQ disclosure) → merge per standing policy → close-out comment (impl+merge SHAs bare, files, checked criteria, the Step-6 filings) → &62 update → branch delete + prune → archive mechanics (prepared inside the MR once the IID is known) → Plan Completion Audit.