fix(reads): the read gate is per-family; a write carrying unchanged data never re-embeds
All checks were successful
CI / Node 22 (push) Successful in 12m15s
CI / Node 24 (push) Successful in 12m12s
CI / Bun (latest) (push) Successful in 12m25s
CI / Integration + conformance (Node 22) (push) Successful in 19m47s

Two cures from the pair's first production adoption, both measured live.

THE READ GATE IS PER-FAMILY. The report-driven gate refused on ANY
provider's not-ready verdict at every read choke point — so a pure
metadata find({ where }) was refused because the VECTOR leg was not
serving, and a deployment's badge reads returned errors for a verdict that
had nothing to do with them. A read may only be refused by the family it
actually consults: metadata reads by the metadata leg (plus graph for a
`connected` filter), vector search by the vector leg, traversal by the
graph leg. Callers name what they need; the existing narration-once-per-
generation and typed-refusal laws are unchanged within a family.

NO RE-EMBED ON UNCHANGED DATA. update() — and its transact() planner —
treated any write that carried `data` as a data change: with
deferEmbedding it queued a landing, and the worker re-embedded and re-landed
a vector for content that had not changed. A host heartbeat re-writing an
unchanged row every few seconds therefore fed a live index-row loop on a
production store. A write carrying the row's current data (structural
compare, key order normalized) is now not a data change: no re-embed, no
deferred landing, no vector rewrite; the metadata write itself still
commits. A real change re-embeds exactly as before.

Pinned in tests/integration/read-gate-scope-and-no-reembed.test.ts — both
pins red-proved against the unfixed code with the production shapes
verbatim. Two health-gate pins that encoded the old brain-global scope are
re-pointed to the family their reads consult.
This commit is contained in:
David Snelling 2026-08-26 13:55:11 -07:00
parent 21e506e802
commit c039411e08
3 changed files with 152 additions and 13 deletions

View file

@ -3598,7 +3598,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// re-embed below — a stale vector left behind with no path to ever
// correct itself (a quiet loss, not the deferred-but-eventually-
// correct flicker the deferEmbedding contract promises).
const hasNewData = params.data !== undefined && params.data !== null
const rawHasNewData = params.data !== undefined && params.data !== null
// NO RE-EMBED ON UNCHANGED DATA: a write carrying the row's CURRENT data
// is not a data change — no re-embed, no deferred landing, no vector
// rewrite. A host heartbeat re-writing an unchanged row every few
// seconds fed a live index-row loop on a production store (each
// "change" landed a vector); the amplifier dies here regardless of how
// often the host writes.
const dataUnchanged = rawHasNewData && Brainy.sameEntityData(params.data, existing.data)
const hasNewData = rawHasNewData && !dataUnchanged
// MT5 deferred re-embedding: the OLD vector keeps serving semantic
// search — stale-but-present, never absent (the flicker law) — until
// the background worker embeds the new data and swaps it atomically.
@ -4209,7 +4217,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// writes while every non-find() read served empty from a not-ready
// provider for 15 minutes. A CHECK only — it never builds; throws a typed
// NotReady error if a provider's health report says it isn't serving.
this.ensureIndexesLoaded()
this.ensureIndexesLoaded(['graph'])
const entityInt = this.graphEntityInt(uuid)
if (entityInt === undefined) return []
const neighborInts = await this.graphIndex.getNeighbors(entityInt, options)
@ -6803,7 +6811,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// READ-SURFACE READINESS GATE (see filterIdsBelted): a CHECK only — it
// never builds. open() already brought every provider to serving before
// init() returned; this throws a typed NotReady error if one isn't.
this.ensureIndexesLoaded()
this.ensureIndexesLoaded(['metadata'])
// Loudly flag a degraded derived index (failed init rebuild, or an
// adopt-forward degraded commit) so a partial result is never mistaken for
@ -6815,6 +6823,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
let params: FindParams<T> =
typeof query === 'string' ? await this.parseNaturalQuery(query) : query
// The vector and graph legs gate only the finds that consult them.
const consultsVector = Boolean(
(params.query && params.query.trim() !== '') || params.vector || params.near
)
if (consultsVector) this.ensureIndexesLoaded(['vector'])
if (params.connected) this.ensureIndexesLoaded(['graph'])
// Id normalization (8.0): resolve the graph-traversal anchor id(s) so a
// caller may constrain by natural key. Each maps to the canonical UUID
// add() stored; real UUIDs pass through. Done once here so every downstream
@ -10663,7 +10678,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// content (see the identical hasNewData in update()); a plain truthy
// check would silently skip re-embedding an emptied value and leave a
// stale vector with no path to ever correct itself.
const hasNewData = params.data !== undefined && params.data !== null
const rawHasNewData = params.data !== undefined && params.data !== null
// No re-embed on unchanged data — the transact() mirror of update()'s rule.
const dataUnchanged = rawHasNewData && Brainy.sameEntityData(params.data, existing.data)
const hasNewData = rawHasNewData && !dataUnchanged
let vector = existing.vector
if (params.vector) {
if (this.dimensions && params.vector.length !== this.dimensions) {
@ -12158,7 +12176,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// writes while every non-find() read served empty from a not-ready
// provider for 15 minutes. A CHECK only — it never builds; throws a typed
// NotReady error if a provider's health report says it isn't serving.
this.ensureIndexesLoaded()
this.ensureIndexesLoaded(['metadata'])
try {
return await this.metadataIndex.getIdsForFilter(filter, opts)
} catch (err) {
@ -14654,7 +14672,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// writes while every non-find() read served empty from a not-ready
// provider for 15 minutes. A CHECK only — it never builds; throws a typed
// NotReady error if a provider's health report says it isn't serving.
this.ensureIndexesLoaded()
this.ensureIndexesLoaded(['graph'])
// 8.0 BigInt boundary: unmapped node → no relations.
const nodeInt = this.graphEntityInt(nodeId)
if (nodeInt === undefined) return []
@ -16543,12 +16561,47 @@ export class Brainy<T = any> implements BrainyInterface<T> {
* `prodLog.warn` ONCE per (provider, `report.generation`) never once per
* read before any throw decision is made.
*/
private ensureIndexesLoaded(): void {
const providers: ReadonlyArray<readonly [string, unknown, new (message: string, originalError?: Error) => BrainyError]> = [
/**
* @description Whether two entity `data` payloads are the same content
* the "no re-embed on unchanged data" comparison. Primitives compare by
* value; objects compare structurally with key order normalized.
* @param a - The incoming data.
* @param b - The stored data.
* @returns `true` when the content is identical.
*/
private static sameEntityData(a: unknown, b: unknown): boolean {
if (a === b) return true
if (a === null || b === null || typeof a !== typeof b) return false
if (typeof a !== 'object') return false
const stable = (v: unknown): string =>
JSON.stringify(v, (_k, val) =>
val && typeof val === 'object' && !Array.isArray(val)
? Object.keys(val as Record<string, unknown>).sort().reduce((o, k) => {
;(o as Record<string, unknown>)[k] = (val as Record<string, unknown>)[k]
return o
}, {} as Record<string, unknown>)
: val
)
try { return stable(a) === stable(b) } catch { return false }
}
private ensureIndexesLoaded(
families: ReadonlyArray<'vector' | 'metadata' | 'graph'> = ['vector', 'metadata', 'graph']
): void {
// PER-FAMILY SCOPE. This gate used to refuse on ANY provider's not-ready
// verdict at every read choke point — so a pure metadata find({where})
// was refused because the VECTOR leg was not serving; a production
// deployment's badge reads returned 500s for exactly that reason on the
// pair's first adoption. A read may only be refused by the family it
// actually consults: metadata reads by the metadata leg (+ graph for a
// `connected` filter), vector search by the vector leg, traversal by the
// graph leg. Callers name what they need.
const all: ReadonlyArray<readonly ['vector' | 'metadata' | 'graph', unknown, new (message: string, originalError?: Error) => BrainyError]> = [
['vector', this.index, VectorIndexNotReadyError],
['metadata', this.metadataIndex, MetadataIndexNotReadyError],
['graph', this.graphIndex, GraphIndexNotReadyError]
]
const providers = all.filter(([name]) => families.includes(name))
for (const [name, provider, ErrorClass] of providers) {
// Migration LOCK (#18) deference: a migrating provider owns its own