Decision-Cache Sweep (#1188, epic &74)
On this page
Status
| Unit | Description | Status |
|---|---|---|
U0 |
This plan page + the ADR-006 |
Done (2026-07-30) — MR !1112, merge ad34fa19 |
U1 (MR-A) |
|
Done (2026-07-30) — MR !1113, merge 84fed342. Both api pins observed RED against the pre-fix image (revision |
U2 (MR-B) |
|
Done (2026-07-31) — MR !1114, merge ee158a0e. Fault e2e observed RED against the MR-A-only image (60.16s timeout still serving marker A), GREEN post-rebuild (converged 4.3s; boot log pins interval 15s/offset 3s). J-review: narrow J5 FAIL remediated (the healthy-DB qualifier added at the two doc sites missing it) + fault-test robustness P3s applied |
Ship |
Battery pushes, MRs merged, J-records, closing comment (3-AC walk), #1188 closed, epic &74 ticked, Plan Completion Audit + archive |
Done (2026-07-31) — J-record notes on !1113/!1114; closing comment with the 3-AC walk + bare SHAs; #1188 auto-closed by !1114; epic &74 ticked (11/15); follow-ups #1216–#1219 stand |
Issue: #1188 (epic &74, weight 3, Plan::DECISION-SWEEP)
ADR: ADR-006 == Amendment — #1188
Follow-ups filed at preflight: #1216 (atomic write+outbox — the ROOT cause; this
sweep is the backstop), #1217 (cache-invalidation subscriber zero coverage), #1218
(vacuous oracle in evaluate_after_update_uses_new_content), #1219 (rules metrics
plumbing + last-success gauge)
Review-round log (plan-lifecycle): round 1 internal (P2s applied) → round 2
EXTERNAL REJECTION of v1 — 7 blocking findings: the probe→snapshot→unconditional-apply
reconciler was race-unsafe (stale overwrite / delete resurrection / wrong removal,
reintroducing the class rules-cache-race-fix.adoc retired), updated_at unenforced
as a token, fail-open compile failure, overclaimed bound, missing method/race tests,
missing docs surface, sizing → full v2 redesign → round 3 (P1: bare-revision CAS
broken by rename-freed-name recreates — the token became the (id, revision) pair;
P2s: lib self-containment, api-docs blocking gate, absent-arm residual recording) →
round 4 delta review: SHIP, all truth tables verified adversarially, the one
condition (dual-residual recording) applied.
Context
Rule-set writes auto-commit; invalidation events are staged in separate warn!-only
transactions (engine.rs:376-396). Scaled peers reload only on
rules.cache_invalidated (main.rs:275-311 → reload_all()); evaluate() never
consults the DB on a hit, and a miss is a hard RuleSetNotFound — a lost event
leaves an updated ruleset stale until restart, and the engine.rs:365-371
"repopulate on next miss" claim is doubly false (AC-2 corrects it).
Adjacent defects to be fixed in-scope: the subscriber’s wholesale reload_all swap can
overwrite a concurrent local API mutation (the same race class the completed
rules-cache-race-fix.adoc (archive-indexed) fixed for the API handlers); and
swap_compiled_decision_if_changed no-ops on metadata-only updates (api.rs:509-527),
leaving the cached audit version stale locally (self-events are skipped).
Design (normative text: the ADR-006 #1188 amendment; this section carries the implementation detail)
D1 — the token
Migration rule_sets.revision BIGINT NOT NULL DEFAULT 1 + BEFORE UPDATE trigger
NEW.revision = OLD.revision + 1. Bump-always, no WHEN guard (the store UPDATE
always writes updated_at/updated_by; metadata-only bumps are REQUIRED by D3;
the placement WHEN-guard precedent was lock-motivated, inapplicable). RuleSet
gains the field by hand; it is API-visible by decision (pre-1.0 additive; lets the
E2E read revision) → cargo xtask api-docs regen rides MR-A (blocking drift gate).
CachedDecision gains revision: i64 (already carries id). The compare token is
the PAIR — see the amendment for why revision alone is broken by rename-recreates.
D2 — the CAS module (craig_rules::decision_refresh, self-contained lib)
Owns CachedDecision (relocated; bin imports by crate path — the
retention_archive wiring), FreshnessRow { name, id, revision }, its own
queries (freshness probe; per-name re-probe returning (id, revision, active)
REGARDLESS of active — no existing store fn distinguishes inactive from absent;
per-name active = true-filtered full fetch), and:
-
apply_if_newer(cache, row, decision)— install if absent, or id differs, or same-id-higher-revision. -
remove_if_confirmed(cache, name, reprobe)— absent → remove; inactive (any id) → remove; ACTIVE → refresh candidate, never removed. -
stale_decision_names(cached: name→(id, rev), probed)— pure diff: refresh = id differs OR revision differs OR probe-only; removal candidates = cache-only. -
reconcile(pool, cache, compile_fn) → DecisionRefreshReport— probe → diff → confirmed removals FIRST (cheap) → per-name fetch+compile+apply; per-name failures recorded, never starve a pass;compile_fnerrors are lib-ownedString`s (the engine’s closure maps `EngineErrorin). Fail-closed compile eviction.DecisionRefreshReport { refreshed/removed/evicted: BTreeSet<String>, failed: BTreeMap<String, String>, probe_error: Option<String> }— sorted, the test oracle.
Recorded residuals + the benign probe-snapshot-lag wasted-fetch: the amendment.
D3 — call-site unification
insert_decision delegates to apply_if_newer (all four API mutation paths
inherit CAS). Subscriber: reload_all() → reconcile(), probe_error → handler
Err (preserves MQ retry). reload_all becomes boot-only. The
swap_compiled_decision_if_changed no-op arm is changed to re-apply the CACHED
decision Arc with the fresh row (metadata refresh). Comment sweep: engine.rs:365-371,
engine.rs:350, main.rs:280 corrected; engine.rs:445-448 scoped to its
craig-authz downstream consumers, not rewritten.
D4 — the worker + knob
CRAIG_RULESDECISION_REFRESH_SECONDS default 300, validated 10..=86400; the
bounded-integer parse is FACTORED SHARED with EVAL_TIMEOUT_MS in config.rs
(clone-twin would regress B8; the existing pinned error-prose tests stay green
untouched) + proptest. spawn_decision_refresh_task: deterministic hash start
offset keyed by instance_id (scaled replicas must not sweep in lockstep —
retention tolerates it only via its advisory lease), then fixed-cadence biased
select!. Honest bound: ≤ ~2×interval + pass work, healthy-DB conditional
(≈10min default; ≈30s devstack). Observability: startup info! (effective
interval), per-pass summary info! when non-empty / debug! when empty, warn!
+ report on probe failure; the gauge is #1219. Compose:
CRAIG_RULESDECISION_REFRESH_SECONDS: ${CRAIG_RULES__DECISION_REFRESH_SECONDS:-15};
.env.example row.
Tests
MR-A (lib seam drives PRODUCTION code; shared devstack craig_rules DB, unique
names + cleanup; #[ignore = "requires devstack"] + connect-else-return; every
test @axis:-tagged):
-
Unit (pure, identity-aware):
stale_decision_namesexhaustive incl. changed-id-same-revision (the rename-recreate pin);apply_if_newertruth table incl. different-id-ANY-revision → install;remove_if_confirmeddecision table. -
CAS order-independence: apply(v1);apply(v2) ≡ apply(v2);apply(v1) → v2; rename-recreate both orders → the new row wins; reconcile-vs-concurrent-insert converges; create-during-reconcile never removed.
-
Method-level vs devstack DB: first pass refreshes; second pass idempotent FOR THE TEST’S ROW — every assertion is scoped to the test’s own rows, because the shared DB has concurrent writers by construction (nextest runs one process per test, so no in-process gate can serialize the group, and the rules api suite mutates
rule_setsin parallel during the battery; a whole-reportis_empty()claim flaked exactly this way and was scoped); direct-SQL update BY ID (RETURNING revision, exactly-one-row) → refresh; deactivate → confirmed removal; rename+recreate → fresh row installs; invalid-JDM → eviction (fail-closed pin);probe_errorvia closed pool. -
Worker tick (paused-time, #784 pattern): generic-refresher loop, start-offset
N intervals → N calls; prompt cancellation.
MR-B — tests/fault/decision_cache_staleness.rs (new tests/fault.rs crate root,
standard allow block; @axis: fault): baseline marker-A via API; content B
INDEPENDENTLY validated (temp-name create → marker B → delete); direct-SQL
content swap BY ID (RETURNING revision); whole poll in tokio::time::timeout(60s)
(4× devstack interval; the compose-knob coupling named in the test doc), 1s
cadence (~60 audit-row side effects, unique-name-keyed, panic-safe cleanup before
asserts); no mid-poll stale assert (flaky — a healthy sweep can fire immediately).
Observed-RED protocol: pre-fix image verified by container creation time → focused
run times out still serving A → record; cargo xtask dev start → GREEN.
Verification
Per MR, gates run separately (check-docs exit 3 = the 4 standing overrides):
scoped clippy -D warnings; --bins (engine units) + --lib (decision_refresh
pure units) + --lib --run-ignored=all -E 'test(decision_refresh)' (method-level);
integration binary(api) or binary(fault); cargo xtask api-docs (MR-A);
scoped cargo llvm-cov nextest -p craig-rules; quality-budgets fail-on-regression;
axis-coverage; plan-lint; local Antora render. Full battery via the pre-push hook
per push. Gate pre-empts: fns ≤40 lines (reconcile decomposed); B8 via the shared
parser; B4 no new allows; F-054 clear (13 counted methods today); no rand dep.
Delivery
-
Preflight — DONE 2026-07-30: assigned, weight 2→3 + note,
Plan::DECISION-SWEEP; follow-ups #1216/#1217/#1218/#1219 filed + related. -
U0: this page + the amendment + nav → plan-lint → battery push → MR → MERGE before code.
-
U1 (MR-A): migration → lib module → delegation/subscriber/metadata → tests → api-docs regen → gates → battery → Draft MR at first push → merge per the standing green-local-pre-push directive. The 500-LOC increment guideline is knowingly exceeded by the test matrix — rationale in the MR description.
-
U2 (MR-B): knob/parser → worker → compose/.env.example → fault E2E (RED protocol) → docs sweep → battery → MR (
Closes #1188) → merge. -
Close-out: J-records; closing comment (3-AC walk + honest bound + follow-ups, bare SHAs); close; epic tick; Plan Completion Audit + archive; cleanup.