Deep audit 2026-09-06, finding F15 (concurrency, moderate). Survived refutation; the "permanent stale knownIndexes until restart" variant from the finder was REFUTED (single multiplexed Redis connection cannot invert JS-order vs Redis-order; see refutation in docs/audits/2026-09-06-deep-audit.md).
Mechanism. Single-namespace reset (src/routes/reset.ts:82-83) runs FT.DROPINDEX then a multi-round-trip SCAN+DEL loop. A concurrent upsert on a cached-known index skips ensureIndex (early return on knownIndexes) and its EVAL can land after the scan passed its key: the hash survives reset while the index is gone → invisible to /query (missing-index → []) but readable via /fetch//range. The next upsert recreates the index and RediSearch backfills, so the window is bounded (upsert-to-next-upsert), not permanent. Same pattern in reset-all (reset.ts:46) and delete-namespace/rename (namespaces.ts).
Candidate fix (needs a product decision). Stop calling FT.DROPINDEX on namespace reset — delete keys only. Kills both interleavings (index never dropped → never stale; late writes stay indexed). Trade-off: namespace dimension becomes sticky across reset (persisted index keeps its DIM; post-reset upsert with a different dimension 400s via the compatibility check). Upstash semantics arguably match the sticky behavior (index dimension is fixed at creation there). Only delete-namespace should keep the drop.
Disposition. Not fixed in the audit pass because it is a visible behavior change (sticky dimension) that needs a deliberate decision, not a drive-by.
Triage guidance: docs/RUNBOOK.md. Audit procedure: docs/workflows/deep-audit.md.
Deep audit 2026-09-06, finding F15 (concurrency, moderate). Survived refutation; the "permanent stale knownIndexes until restart" variant from the finder was REFUTED (single multiplexed Redis connection cannot invert JS-order vs Redis-order; see refutation in
docs/audits/2026-09-06-deep-audit.md).Mechanism. Single-namespace reset (
src/routes/reset.ts:82-83) runsFT.DROPINDEXthen a multi-round-trip SCAN+DEL loop. A concurrent upsert on a cached-known index skipsensureIndex(early return onknownIndexes) and its EVAL can land after the scan passed its key: the hash survives reset while the index is gone → invisible to/query(missing-index →[]) but readable via/fetch//range. The next upsert recreates the index and RediSearch backfills, so the window is bounded (upsert-to-next-upsert), not permanent. Same pattern in reset-all (reset.ts:46) and delete-namespace/rename (namespaces.ts).Candidate fix (needs a product decision). Stop calling
FT.DROPINDEXon namespace reset — delete keys only. Kills both interleavings (index never dropped → never stale; late writes stay indexed). Trade-off: namespace dimension becomes sticky across reset (persisted index keeps its DIM; post-reset upsert with a different dimension 400s via the compatibility check). Upstash semantics arguably match the sticky behavior (index dimension is fixed at creation there). Only delete-namespace should keep the drop.Disposition. Not fixed in the audit pass because it is a visible behavior change (sticky dimension) that needs a deliberate decision, not a drive-by.
Triage guidance:
docs/RUNBOOK.md. Audit procedure:docs/workflows/deep-audit.md.