fix: re-validate find() results against the predicate (index-integrity guard)

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 <noreply@anthropic.com>
This commit is contained in:
David Snelling 2026-06-24 16:36:50 -07:00
parent 9593a27338
commit 7b5db0ddf9
2 changed files with 154 additions and 1 deletions

View file

@ -1407,6 +1407,43 @@ export class Brainy<T = any> implements BrainyInterface<T> {
} }
} }
/**
* 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<T>, params: FindParams<T>): 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<string, unknown>
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!) * Convert a noun from storage to an entity (SIMPLIFIED!)
* *
@ -3177,7 +3214,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
const isHidden = (id: string): boolean => hiddenIds.size > 0 && hiddenIds.has(id) const isHidden = (id: string): boolean => hiddenIds.size > 0 && hiddenIds.has(id)
const startTime = Date.now() const startTime = Date.now()
const result = await (async () => { let result = await (async () => {
let results: Result<T>[] = [] let results: Result<T>[] = []
// Distinguish between search criteria (need vector search) and filter criteria (metadata only) // Distinguish between search criteria (need vector search) and filter criteria (metadata only)
@ -3618,6 +3655,21 @@ export class Brainy<T = any> implements BrainyInterface<T> {
return results.slice(finalOffset, finalOffset + limit) 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 // Record performance for auto-tuning
const duration = Date.now() - startTime const duration = Date.now() - startTime
recordQueryPerformance(duration, result.length) recordQueryPerformance(duration, result.length)

View file

@ -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<any>
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<string[]> => {
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<string[]> => {
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])
})
})