From 7b5db0ddf91b8e7a9ee5a50c6ee15f7ae30237ee Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 24 Jun 2026 16:36:50 -0700 Subject: [PATCH] fix: re-validate find() results against the predicate (index-integrity guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find() trusted the metadata index's returned ids: it loaded each id's entity and returned it, checking only that the entity existed — never that it actually matched the query. The indexes are acceleration structures; the loaded entity is ground truth. A stale or cross-bucket index posting (e.g. an id left in a field-value bucket by a delete or an `update({ field: undefined })`) therefore surfaced an entity matching NEITHER the requested type NOR the where filter — a production report saw a timeslot (NounType.Event) returned for `find({ type: Person, where: { entityType: 'staff' } })`. Add a single egress chokepoint after the result IIFE that re-validates every result with a new `entityMatchesFindParams` predicate (type/subtype/where/ service/excludeVFS, reusing matchesMetadataFilter for the where leg). It covers every find() branch (metadata, vector, text, proximity, graph) and similar() (which delegates to find) in one place. A no-op on a healthy index; on a corrupted one it drops the bad row instead of returning a phantom. Full unit gate green (1535) confirms the where re-validation is consistent with the index (no valid rows dropped). This is the safety net. The durable corruption itself lives in the metadata index that owns the query (the native provider when present) and is addressed separately. Co-Authored-By: Claude Opus 4.8 --- src/brainy.ts | 54 +++++++++- .../brainy/find-index-integrity-guard.test.ts | 101 ++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 tests/unit/brainy/find-index-integrity-guard.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index 7704f436..7975c42b 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -1407,6 +1407,43 @@ export class Brainy implements BrainyInterface { } } + /** + * Index-integrity predicate: does this loaded entity actually satisfy the + * find() metadata predicate (type/subtype/service/excludeVFS/where)? + * + * find() trusts the ids the metadata index returns; the loaded entity is + * ground truth. A stale or cross-bucket index posting (e.g. an id left in a + * field-value bucket by a delete or an `update({ field: undefined })`) can + * otherwise surface an entity matching NEITHER the requested type NOR the + * where filter. Re-validating each result against this predicate before + * returning restores the invariant "find never returns an entity that does + * not match its query." The structural legs are exact; the `where` leg reuses + * `matchesMetadataFilter` — the same matcher the filter path itself uses — so + * a healthy index is a no-op. + */ + private entityMatchesFindParams(entity: Entity, params: FindParams): boolean { + if (params.type !== undefined) { + const types = Array.isArray(params.type) ? params.type : [params.type] + if (!types.includes(entity.type)) return false + } + if (params.subtype !== undefined) { + const subtypes = Array.isArray(params.subtype) ? params.subtype : [params.subtype] + if (entity.subtype === undefined || !subtypes.includes(entity.subtype)) return false + } + if (params.service !== undefined && entity.service !== params.service) { + return false + } + if (params.excludeVFS === true) { + const md = (entity.metadata ?? {}) as Record + if (md.vfsType !== undefined) return false + if (md.isVFSEntity === true || md.isVFS === true) return false + } + if (params.where !== undefined) { + if (!matchesMetadataFilter((entity.metadata ?? {}) as any, params.where as any)) return false + } + return true + } + /** * Convert a noun from storage to an entity (SIMPLIFIED!) * @@ -3177,7 +3214,7 @@ export class Brainy implements BrainyInterface { const isHidden = (id: string): boolean => hiddenIds.size > 0 && hiddenIds.has(id) const startTime = Date.now() - const result = await (async () => { + let result = await (async () => { let results: Result[] = [] // Distinguish between search criteria (need vector search) and filter criteria (metadata only) @@ -3618,6 +3655,21 @@ export class Brainy implements BrainyInterface { return results.slice(finalOffset, finalOffset + limit) })() + // Index-integrity guard — applied ONCE here so every find() path (metadata, + // vector, text, proximity, graph) is covered uniformly. The indexes are + // acceleration structures; the loaded entity is ground truth. Re-validate + // each result against the query predicate so a stale or cross-bucket index + // entry — e.g. an id left in a field-value posting by a delete or an + // `update({ field: undefined })` — can never surface an entity that does not + // actually match `type`/`subtype`/`where`/`service`/`excludeVFS`. On a + // healthy index this is a no-op; on a corrupted one it drops the bad row + // instead of returning a phantom. + if (result.length > 0) { + result = result.filter( + (r) => r.entity != null && this.entityMatchesFindParams(r.entity, params) + ) + } + // Record performance for auto-tuning const duration = Date.now() - startTime recordQueryPerformance(duration, result.length) diff --git a/tests/unit/brainy/find-index-integrity-guard.test.ts b/tests/unit/brainy/find-index-integrity-guard.test.ts new file mode 100644 index 00000000..41ec550e --- /dev/null +++ b/tests/unit/brainy/find-index-integrity-guard.test.ts @@ -0,0 +1,101 @@ +/** + * @module find-index-integrity-guard.test + * @description Regression for the "phantom row" class (Venue 2026-06-24): a + * corrupt/stale metadata-index posting must never surface an entity that does + * not actually match the find() predicate. + * + * The metadata index is an acceleration structure; the loaded entity is ground + * truth. find() re-validates every result against `type`/`subtype`/`where`/ + * `service` at a single egress chokepoint (`entityMatchesFindParams`) before + * returning. These tests inject a poisoned `getIdsForFilter` (the exact failure + * mode seen in production: a native index returns an id whose record matches + * NEITHER the type nor the where filter) and assert the phantom is dropped while + * the genuine matches survive. + */ +import { describe, it, expect, beforeEach } from 'vitest' +import { Brainy } from '../../../src/brainy' +import { NounType } from '../../../src/types/graphTypes' + +describe('find() index-integrity guard (phantom row class)', () => { + let brain: Brainy + let staffId: string + let timeslotId: string + let customerId: string + + beforeEach(async () => { + brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + + // A real staff Person (the only legitimate match for the roster query). + staffId = await brain.add({ + data: 'Kristine Perry', + type: NounType.Person, + metadata: { entityType: 'staff', firstName: 'Kristine', lastName: 'Perry' } + }) + // A timeslot Event — matches NEITHER type:Person NOR where.entityType:'staff' + // (the production phantom was exactly this: a NounType.Event timeslot). + timeslotId = await brain.add({ + data: 'kitten-experience 2026-08-19 12:00', + type: NounType.Event, + metadata: { entityType: 'timeslot', date: '2026-08-19', startTime: '12:00' } + }) + // A Person that matches type:Person but NOT where.entityType:'staff' — proves + // the WHERE leg of the guard, not just the structural type leg. + customerId = await brain.add({ + data: 'Walk-in Customer', + type: NounType.Person, + metadata: { entityType: 'customer', firstName: 'Walk-in' } + }) + }) + + it('healthy index: the discriminant query returns only the staff Person', async () => { + const rows = await brain.find({ type: NounType.Person, where: { entityType: 'staff' }, limit: 100 }) + expect(rows.map((r) => r.id)).toEqual([staffId]) + }) + + it('drops a phantom of the WRONG TYPE that a corrupt index leaks into the where bucket', async () => { + // Poison the metadata index so it returns the timeslot's id for the staff + // query — the precise production corruption (a cross-bucket/stale posting). + const mi = (brain as any).metadataIndex + const original = mi.getIdsForFilter.bind(mi) + mi.getIdsForFilter = async (filter: any): Promise => { + const ids = await original(filter) + return ids.includes(timeslotId) ? ids : [...ids, timeslotId] + } + + const rows = await brain.find({ type: NounType.Person, where: { entityType: 'staff' }, limit: 100 }) + + // The timeslot (NounType.Event, entityType:'timeslot') must NOT appear. + expect(rows.map((r) => r.id)).not.toContain(timeslotId) + // The genuine staff row survives. + expect(rows.map((r) => r.id)).toEqual([staffId]) + + mi.getIdsForFilter = original + }) + + it('drops a phantom of the RIGHT TYPE but WRONG where-value (exercises the where leg)', async () => { + // The customer is a Person (passes type) but entityType:'customer' (fails + // where). A corrupt index leaking it into the 'staff' bucket must still be + // rejected by the where re-validation. + const mi = (brain as any).metadataIndex + const original = mi.getIdsForFilter.bind(mi) + mi.getIdsForFilter = async (filter: any): Promise => { + const ids = await original(filter) + return ids.includes(customerId) ? ids : [...ids, customerId] + } + + const rows = await brain.find({ type: NounType.Person, where: { entityType: 'staff' }, limit: 100 }) + + expect(rows.map((r) => r.id)).not.toContain(customerId) + expect(rows.map((r) => r.id)).toEqual([staffId]) + + mi.getIdsForFilter = original + }) + + it('a healthy query for the phantom\'s own bucket still returns it (guard is not over-eager)', async () => { + // Sanity: the guard must only drop genuine non-matches. Querying the + // timeslot's real bucket returns it normally. + const rows = await brain.find({ type: NounType.Event, where: { entityType: 'timeslot' }, limit: 100 }) + expect(rows.map((r) => r.id)).toEqual([timeslotId]) + }) +})