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

@ -186,6 +186,8 @@ describe('health gate (b) — unledgered is unknown: never blocks a serving prov
})
describe('health gate (c) — degraded-but-serving narrates once per generation', () => {
// PER-FAMILY LAW (10.4.1): a metadata find() consults the METADATA leg only — the
// degraded report lives on the family the read actually consults.
it('a heal:"repair" failure serves; narrates once per generation, twice across a generation bump', async () => {
const brain = new Brainy(createTestConfig({ silent: true }))
await brain.init()
@ -195,7 +197,7 @@ describe('health gate (c) — degraded-but-serving narrates once per generation'
const internals = internalsOf(brain)
let generation = 1
internals.index.healthReport = () =>
internals.metadataIndex.healthReport = () =>
healthReport({
provider: 'vector',
serving: true,
@ -216,7 +218,7 @@ describe('health gate (c) — degraded-but-serving narrates once per generation'
await expect(brain.find({ where: { team: 'atlas' } })).resolves.toHaveLength(1)
expect(countNarrations()).toBe(2) // generation bumped — a second narration
delete internals.index.healthReport
delete internals.metadataIndex.healthReport
})
})
@ -332,6 +334,7 @@ describe('health gate (f) — the ceremony door: explicit rebuild bypasses invar
})
describe('health gate (g) — a throwing healthReport() is a contract violation, never read as healthy', () => {
// PER-FAMILY LAW (10.4.1): the throwing report sits on the family the read consults.
it('healthReport() that throws refuses loudly with the typed NotReady error naming the throw', async () => {
const brain = new Brainy(createTestConfig({ silent: true }))
await brain.init()
@ -340,13 +343,13 @@ describe('health gate (g) — a throwing healthReport() is a contract violation,
await brain.flush()
const internals = internalsOf(brain)
internals.index.healthReport = () => {
internals.metadataIndex.healthReport = () => {
throw new Error('accelerator: mmap window busy')
}
await expect(brain.find({ where: { team: 'atlas' } })).rejects.toBeInstanceOf(VectorIndexNotReadyError)
await expect(brain.find({ where: { team: 'atlas' } })).rejects.toBeInstanceOf(MetadataIndexNotReadyError)
await expect(brain.find({ where: { team: 'atlas' } })).rejects.toThrow(/mmap window busy/)
delete internals.index.healthReport
delete internals.metadataIndex.healthReport
})
})

View file

@ -0,0 +1,83 @@
/**
* @module tests/integration/read-gate-scope-and-no-reembed
* @description Two cures from the pair's first production adoption:
* (1) THE READ GATE IS PER-FAMILY a not-serving VECTOR leg refuses vector
* search only; a pure metadata find({ where }) and graph traversal keep
* serving. The brain-global gate refused a deployment's badge reads for a
* vector-leg verdict that had nothing to do with them.
* (2) NO RE-EMBED ON UNCHANGED DATA an update() carrying the row's current
* data lands no vector, defers no embed, rewrites nothing. A host
* heartbeat re-writing an unchanged row fed a live index-row loop.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { Brainy, VectorIndexNotReadyError } from '../../src/index.js'
describe('read gate scope + no re-embed on unchanged data', () => {
let dir: string
let brain: any
beforeEach(async () => {
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-gate-scope-'))
brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir }, silent: true, dimensions: 384 })
await brain.init()
})
afterEach(async () => {
await brain.close?.().catch(() => {})
fs.rmSync(dir, { recursive: true, force: true })
})
it('a not-serving VECTOR leg refuses vector search only — metadata and graph reads keep serving', async () => {
const a = await brain.add({ data: 'employee alpha', type: 'person', metadata: { status: 'active' } })
const b = await brain.add({ data: 'employee beta', type: 'person', metadata: { status: 'active' } })
await brain.relate({ from: a, to: b, type: 'relatedTo' })
await brain.flush()
// The vector provider says it is NOT serving (a rebuild-class failure).
brain.index.healthReport = () => ({
provider: 'vector', healthy: false, serving: false, generation: 7, unledgered: [],
invariants: [{ name: 'node-coverage', holds: false, heal: 'rebuild', detail: 'posted 0 < canonical 2', source: 'ledger' }],
checkedAt: 1, durationMs: 1
})
try {
const byStatus = await brain.find({ where: { status: 'active' } })
expect(byStatus.map((r: any) => r.id).sort(), 'metadata find serves').toEqual([a, b].sort())
const rel = await brain.related(a)
expect(rel.length, 'graph traversal serves').toBe(1)
await expect(brain.find({ query: 'employee' }), 'vector search refuses typed').rejects.toBeInstanceOf(VectorIndexNotReadyError)
} finally {
delete brain.index.healthReport
}
})
it('update() with the row\'s current data re-embeds nothing; a real change re-embeds', async () => {
const id = await brain.add({ data: 'invoice 1042 pending', type: 'document', metadata: { n: 1 } })
await brain.flush()
const before = (await brain.get(id, { includeVectors: true })).vector
const ledgerBefore = await brain.storage.getCanonicalCounts()
const logBefore = (await brain.transactionLog({ limit: 50 })).length
// The heartbeat shape: same data, re-written, deferred.
for (let i = 0; i < 3; i++) {
await brain.update({ id, data: 'invoice 1042 pending', metadata: { n: 1, tick: i }, deferEmbedding: true })
}
await brain.flush()
const after = (await brain.get(id, { includeVectors: true })).vector
const ledgerAfter = await brain.storage.getCanonicalCounts()
const log = await brain.transactionLog({ limit: 50 })
expect(after, 'vector untouched by unchanged-data writes').toEqual(before)
expect(ledgerAfter.vectors.all, 'vectored ledger untouched').toBe(ledgerBefore.vectors.all)
expect(log.filter((e: any) => e.origin === 'system:embed-landing').length, 'no landing commit for unchanged data').toBe(0)
expect(log.length - logBefore, 'the metadata writes themselves still commit').toBe(3)
// A REAL change re-embeds (deferred → the worker lands it).
await brain.update({ id, data: 'invoice 1042 PAID', deferEmbedding: true })
await brain.flush()
const changed = (await brain.get(id, { includeVectors: true })).vector
expect(changed, 'a real data change re-embeds').not.toEqual(before)
expect((await brain.storage.getCanonicalCounts()).vectors.all, 'a re-embed of a vectored row never double-counts').toBe(ledgerBefore.vectors.all)
})
})