From 8d45f964e9880dc4e82966a003144f8e6d3be2f7 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Thu, 20 Aug 2026 11:51:29 -0700 Subject: [PATCH] feat(repair): repairIndex returns the per-family receipt and narrates its summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repairIndex() now returns a RepairReport: one row per repair family (orphaned containers, count rollups, VFS containment, metadata corruption, write quarantine, each provider's invariant pass, degraded-read state) with checked / healed counts and an explicit skip reason for anything not run — no silent rows. A summary line narrates families checked and heals applied. This is the receipts half of the graph-trust program's ask: a repair that cannot show its work per store is a repair nobody can audit. Pinned: a healthy store yields a complete zero-heal receipt with every family accounted; a manufactured pre-8.3.1 ghost container appears in the receipt as a counted heal. Additive: void-callers are unaffected. --- src/brainy.ts | 79 +++++++++++++++++++++++-- src/index.ts | 4 +- src/types/brainy.types.ts | 21 +++++++ tests/integration/repair-report.test.ts | 71 ++++++++++++++++++++++ 4 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 tests/integration/repair-report.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index 38980a4d..bd2e540b 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -144,7 +144,9 @@ import { ScoreExplanation, FillSubtypeRule, FillSubtypeRules, - FillSubtypesResult + FillSubtypesResult, + RepairReport, + RepairFamilyReport } from './types/brainy.types.js' import { NounType, VerbType, TypeUtils } from './types/graphTypes.js' import { @@ -17088,8 +17090,13 @@ export class Brainy implements BrainyInterface { ) } - async repairIndex(): Promise { + async repairIndex(): Promise { await this.ensureInitialized() + const startedAt = Date.now() + const families: RepairFamilyReport[] = [] + const record = (family: string, entry: Omit): void => { + families.push({ family, ...entry }) + } // Prune orphaned canonical containers left by the pre-8.3.1 partial-delete // defect: a delete that removed the metadata (content) leg but left the @@ -17105,13 +17112,23 @@ export class Brainy implements BrainyInterface { } if (typeof pruner.pruneOrphanedEntities === 'function') { const orphans = await pruner.pruneOrphanedEntities() - if (orphans.nouns.length + orphans.verbs.length > 0) { + const pruned = orphans.nouns.length + orphans.verbs.length + record('orphaned-containers', { + checked: true, + healed: pruned, + ...(pruned > 0 + ? { detail: `${orphans.nouns.length} noun + ${orphans.verbs.length} verb container(s) pruned` } + : {}) + }) + if (pruned > 0) { prodLog.warn( `[Brainy] repairIndex() pruned ${orphans.nouns.length} orphaned noun + ` + `${orphans.verbs.length} orphaned verb container(s) left by a pre-8.3.1 ` + `partial delete.` ) } + } else { + record('orphaned-containers', { checked: false, healed: 0, skipped: 'storage has no container model' }) } // SANCTIONED RECOUNT — unconditional, not gated on orphans found: the // persisted counters can be inflated over perfectly clean shelves (deletes @@ -17122,6 +17139,14 @@ export class Brainy implements BrainyInterface { // canonical walk and persists them. await pruner.rebuildTypeCounts?.() await pruner.rebuildSubtypeCounts?.() + record('count-rollups', { + checked: typeof pruner.rebuildTypeCounts === 'function', + healed: 0, + detail: typeof pruner.rebuildTypeCounts === 'function' + ? 'recomputed from one canonical walk (unconditional)' + : undefined, + ...(typeof pruner.rebuildTypeCounts !== 'function' ? { skipped: 'storage has no count rollups' } : {}) + }) // The recount changed the rollup truth — re-stamp the entity tree so the // stamp's invariants match the healed counters (repair leaves a coherent @@ -17135,6 +17160,13 @@ export class Brainy implements BrainyInterface { // containment edges are touched. Loud per repair. if (this._vfsInitialized && this._vfs) { const containment = await this._vfs.repairContainment() + record('vfs-containment', { + checked: true, + healed: containment.removed + containment.restored, + ...(containment.removed + containment.restored > 0 + ? { detail: `${containment.removed} stale edge(s) removed, ${containment.restored} restored` } + : {}) + }) if (containment.removed + containment.restored > 0) { prodLog.warn( `[Brainy] repairIndex() reconciled VFS containment: removed ${containment.removed} ` + @@ -17143,13 +17175,19 @@ export class Brainy implements BrainyInterface { } } + if (!this._vfsInitialized || !this._vfs) { + record('vfs-containment', { checked: false, healed: 0, skipped: 'VFS not initialized' }) + } + await this.metadataIndex.detectAndRepairCorruption() + record('metadata-corruption', { checked: true, healed: 0, detail: 'detect-and-repair pass ran (see its own narration for repairs)' }) // Lift a failed-rollback write-quarantine: force a full rebuild so the // derived indexes are provably reconciled with canonical, then clear the // flag so writes resume. if (this.storeInconsistency) { await this.rebuildIndexesIfNeeded(true) const cleared = this.storeInconsistency + record('write-quarantine', { checked: true, healed: 1, detail: `lifted (${cleared.records.length} record(s) reconciled)` }) this.storeInconsistency = null prodLog.warn( `[Brainy] repairIndex() reconciled the store and LIFTED the write-quarantine ` + @@ -17166,20 +17204,38 @@ export class Brainy implements BrainyInterface { validateInvariants?: () => Promise rebuild?: () => Promise } | null - if (!p || typeof p.validateInvariants !== 'function' || typeof p.rebuild !== 'function') continue + if (!p || typeof p.validateInvariants !== 'function' || typeof p.rebuild !== 'function') { + record(`provider:${(provider as { constructor?: { name?: string } })?.constructor?.name ?? 'unknown'}`, { + checked: false, healed: 0, skipped: 'no validateInvariants/rebuild contract' + }) + continue + } let report: ProviderInvariantReport try { report = await p.validateInvariants() - } catch { + } catch (err) { + record(`provider:unknown`, { checked: false, healed: 0, skipped: `validateInvariants threw: ${(err as Error).message}` }) continue // a throwing validateInvariants is surfaced by validateIndexConsistency; skip repair here } - if (report.healthy) continue + if (report.healthy) { + record(`provider:${report.provider}`, { checked: true, healed: 0 }) + continue + } if (report.invariants.some((i) => !i.holds && i.heal === 'rebuild')) { + record(`provider:${report.provider}`, { + checked: true, healed: 1, + detail: `rebuilt from canonical (failing: ${report.invariants.filter((i) => !i.holds).map((i) => i.name).join(', ')})` + }) prodLog.warn( `[Brainy] repairIndex(): provider '${report.provider}' has a failing invariant ` + `requiring a rebuild — reconciling its derived state from canonical.` ) await p.rebuild() + } else { + record(`provider:${report.provider}`, { + checked: true, healed: 0, + detail: `unhealthy without a rebuild verdict (failing: ${report.invariants.filter((i) => !i.holds).map((i) => `${i.name}→${i.heal}`).join(', ')})` + }) } } // detectAndRepairCorruption() above rebuilt the derived indexes from @@ -17190,7 +17246,18 @@ export class Brainy implements BrainyInterface { this._indexDegradedIds.clear() this._indexRebuildFailed = null this._degradedReadWarned = false + record('degraded-read-state', { checked: true, healed: 1, detail: 'degraded ids cleared, read-path warning re-armed' }) } + + const healedTotal = families.reduce((n, f) => n + f.healed, 0) + const report: RepairReport = { families, healedTotal, durationMs: Date.now() - startedAt } + prodLog.warn( + `[Brainy] repairIndex complete in ${report.durationMs}ms — ` + + `${families.filter((f) => f.checked).length}/${families.length} families checked, ` + + `${healedTotal} heal(s): ` + + families.map((f) => `${f.family}=${f.checked ? f.healed : 'skipped'}`).join(', ') + ) + return report } /** diff --git a/src/index.ts b/src/index.ts index 2dfc8352..765c20b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,7 +80,9 @@ export type { AggregationOp, TimeWindowGranularity, GroupByDimension, - AggregationProvider + AggregationProvider, + RepairReport, + RepairFamilyReport, } from './types/brainy.types.js' // Read-barrier contract (waitForIndexed): the leg names, the options, and diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts index 75a63d44..ce3236e9 100644 --- a/src/types/brainy.types.ts +++ b/src/types/brainy.types.ts @@ -1192,6 +1192,27 @@ export interface RelateManyParams { /** * Batch result */ +/** + * One family's row in a {@link RepairReport} — what repairIndex() checked, + * what it healed, and why anything was skipped. The receipts venue's graph + * trust program asked for: a repair that cannot show its work is a repair + * nobody can trust. + */ +export interface RepairFamilyReport { + family: string + checked: boolean + healed: number + detail?: string + skipped?: string +} + +/** The full receipt returned by repairIndex(). */ +export interface RepairReport { + families: RepairFamilyReport[] + healedTotal: number + durationMs: number +} + export interface BatchResult { successful: T[] // Successfully processed items failed: Array<{ // Failed items with errors diff --git a/tests/integration/repair-report.test.ts b/tests/integration/repair-report.test.ts new file mode 100644 index 00000000..273eee2e --- /dev/null +++ b/tests/integration/repair-report.test.ts @@ -0,0 +1,71 @@ +/** + * @module tests/integration/repair-report + * @description repairIndex() returns the per-family receipt (checked / + * healed / skipped-with-reason per family) and narrates a summary — the + * "repair that shows its work" half of the graph-trust program's ask. A + * repair nobody can audit is a repair nobody can trust. + */ +import { describe, it, expect, afterEach } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Brainy } from '../../src/index.js' +import { NounType } from '../../src/types/graphTypes.js' + +type RawBox = { + storage: { writeNounRaw(id: string, r: { metadata: unknown; vector: unknown }): Promise } +} + +const dirs: string[] = [] +const brains: Brainy[] = [] +afterEach(async () => { + for (const b of brains.splice(0)) await b.close().catch(() => {}) + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) +}) + +describe('repairIndex per-family receipt', () => { + it('a healthy store gets a complete zero-heal receipt — every family accounted, none silent', async () => { + const dir = mkdtempSync(join(tmpdir(), 'brainy-repair-clean-')) + dirs.push(dir) + const brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false, silent: true }) + await brain.init() + brains.push(brain) + await brain.add({ data: 'healthy row', type: NounType.Document, metadata: { n: 1 } }) + await brain.flush() + + const report = await brain.repairIndex() + expect(report.families.length, 'every family reports a row').toBeGreaterThanOrEqual(5) + const names = report.families.map((f) => f.family) + for (const expected of ['orphaned-containers', 'count-rollups', 'metadata-corruption']) { + expect(names, `family ${expected} accounted`).toContain(expected) + } + // Every row is either checked or carries its skip reason — no silent rows. + for (const f of report.families) { + expect(f.checked || !!f.skipped, `${f.family} is checked or explains itself`).toBe(true) + } + expect(report.healedTotal).toBe(0) + expect(report.durationMs).toBeGreaterThanOrEqual(0) + }, 120000) + + it('a manufactured ghost container appears in the receipt as a heal', async () => { + const dir = mkdtempSync(join(tmpdir(), 'brainy-repair-ghost-')) + dirs.push(dir) + const brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false, silent: true }) + await brain.init() + brains.push(brain) + await brain.add({ data: 'real row', type: NounType.Document, metadata: { n: 1 } }) + await brain.flush() + // The pre-8.3.1 ghost shape: a vector leg with no content leg. + const storage = (brain as unknown as RawBox).storage + await storage.writeNounRaw('00000000-0000-7000-8000-00000000dead', { + metadata: null, + vector: { vector: [0.1, 0.2], noun: 'document' } + }) + + const report = await brain.repairIndex() + const orphans = report.families.find((f) => f.family === 'orphaned-containers') + expect(orphans?.checked).toBe(true) + expect(orphans!.healed, 'the ghost was pruned and receipted').toBeGreaterThan(0) + expect(report.healedTotal).toBeGreaterThan(0) + }, 120000) +})