feat: validateIndexConsistency delegates to provider invariants (ADR-004 Pass 3)

validateIndexConsistency() was blind to native providers — it only ran the JS
metadata index's own check, so a native provider whose manifest/segments/counts
had diverged still read as "healthy". Per ADR-004 §6 it now feature-detects and
aggregates each provider's optional validateInvariants() (a never-throwing,
<50ms self-report of its own cross-layer invariants), names any failing
invariant with its numbers in the recommendation, and exposes the per-provider
reports. A provider that violates the never-throw contract is surfaced as
unhealthy, never swallowed.

repairIndex() now reconciles NATIVE derived state from canonical too: it consults
each provider's invariants and calls rebuild() on any whose failing invariant
asks for heal:'rebuild' — the native counterpart of detectAndRepairCorruption().

New provider surface: optional validateInvariants(); new exported types
ProviderInvariantReport / InvariantResult / InvariantHeal. Additive, no break.
Cor implements the hook in 3.0.15 (M2); brainy builds against the shape now
(feature-detected, inert until a provider exposes it). 5 tests.
This commit is contained in:
David Snelling 2026-07-13 14:43:29 -07:00
parent 7b75f932d4
commit 6bcb54f0d9
4 changed files with 299 additions and 19 deletions

View file

@ -190,7 +190,7 @@ import type {
HistoryVersion
} from './db/types.js'
import { stableDeepEqual } from './db/stableEqual.js'
import type { VersionedIndexProvider } from './plugin.js'
import type { VersionedIndexProvider, ProviderInvariantReport } from './plugin.js'
import type { Operation, TransactionFunction } from './transaction/types.js'
/**
@ -12530,33 +12530,96 @@ export class Brainy<T = any> implements BrainyInterface<T> {
entityCount: number
indexEntryCount: number
recommendation: string | null
/** Cross-layer (ADR-004 §6): each provider's own invariant self-report, when
* it exposes validateInvariants(). Absent providers are simply not listed. */
providers?: ProviderInvariantReport[]
}> {
await this.ensureInitialized()
const result = await this.metadataIndex.validateConsistency()
// Fold in a non-fatal index-rebuild failure recorded at init() so the degraded
// state is observable through the same health surface (not just the console).
// Cross-layer integrity (ADR-004 §6): validateConsistency() above only sees the
// JS metadata index — it is BLIND to a native provider whose manifest ↔
// segments ↔ counts have diverged. Delegate to each provider's own
// validateInvariants() and aggregate, so "healthy-while-broken" is impossible.
const providers = await this.collectProviderInvariants()
const brokenProviders = providers.filter((p) => !p.healthy)
let healthy = result.healthy
const notes: string[] = []
if (result.recommendation) notes.push(result.recommendation)
if (this._indexRebuildFailed) {
return {
...result,
healthy: false,
recommendation:
`Index rebuild failed at init() (degraded — queries may be incomplete): ` +
`${this._indexRebuildFailed.message}. Rebuild the indexes and re-open.` +
(result.recommendation ? ` Also: ${result.recommendation}` : '')
}
healthy = false
notes.unshift(
`Index rebuild failed at init() (degraded — queries may be incomplete): ` +
`${this._indexRebuildFailed.message}. Rebuild the indexes and re-open.`
)
}
if (this._indexDegradedIds.size > 0) {
return {
...result,
healthy: false,
recommendation:
`${this._indexDegradedIds.size} record(s) committed via adopt-forward ` +
healthy = false
notes.unshift(
`${this._indexDegradedIds.size} record(s) committed via adopt-forward ` +
`failed-rollback recovery — the derived index may be incomplete for them. ` +
`Run repairIndex() to reconcile.` +
(result.recommendation ? ` Also: ${result.recommendation}` : '')
`Run repairIndex() to reconcile.`
)
}
for (const p of brokenProviders) {
healthy = false
const failing = p.invariants.filter((i) => !i.holds)
notes.unshift(
`Provider '${p.provider}' reports ${failing.length} failing invariant(s): ` +
failing.map((i) => `${i.name} (${i.detail}; heal=${i.heal})`).join('; ') +
`. Run repairIndex() to reconcile from canonical.`
)
}
return {
...result,
healthy,
recommendation: notes.length > 0 ? notes.join(' ') : null,
...(providers.length > 0 ? { providers } : {})
}
}
/**
* @description Feature-detect + call each index provider's OPTIONAL
* `validateInvariants()` (ADR-004 §6) and collect the reports. The contract is
* that `validateInvariants()` NEVER throws and is bounded <50ms but if a
* provider violates that, the throw is turned into an UNHEALTHY synthetic
* report (loud), never swallowed into "healthy". Providers that do not expose
* the hook are simply omitted (the JS baseline validates via
* `metadataIndex.validateConsistency()`).
* @returns One report per provider that exposes `validateInvariants()`.
*/
private async collectProviderInvariants(): Promise<ProviderInvariantReport[]> {
const reports: ProviderInvariantReport[] = []
const providers: unknown[] = [this.metadataIndex, this.index, this.graphIndex]
for (const provider of providers) {
const fn = (provider as { validateInvariants?: () => Promise<ProviderInvariantReport> } | null)
?.validateInvariants
if (typeof fn !== 'function') continue
try {
const report = await fn.call(provider)
if (report && Array.isArray(report.invariants)) reports.push(report)
} catch (err) {
reports.push({
provider: 'unknown',
healthy: false,
serving: false,
invariants: [
{
name: 'validate-invariants-threw',
holds: false,
detail: `validateInvariants() threw (contract violation — it must never throw): ${(err as Error).message}`,
heal: 'rebuild'
}
],
checkedAt: Date.now(),
durationMs: 0
})
}
}
return result
return reports
}
/**
@ -15112,6 +15175,31 @@ export class Brainy<T = any> implements BrainyInterface<T> {
`Writes are re-enabled.`
)
}
// Cross-layer repair (ADR-004 §6): repairIndex must reconcile NATIVE derived
// state from canonical, not just the JS metadata index. Consult each provider's
// own validateInvariants() and rebuild any whose failing invariant asks for it
// (heal: 'rebuild') — the native counterpart of detectAndRepairCorruption().
for (const provider of [this.metadataIndex, this.index, this.graphIndex]) {
const p = provider as {
validateInvariants?: () => Promise<ProviderInvariantReport>
rebuild?: () => Promise<void>
} | null
if (!p || typeof p.validateInvariants !== 'function' || typeof p.rebuild !== 'function') continue
let report: ProviderInvariantReport
try {
report = await p.validateInvariants()
} catch {
continue // a throwing validateInvariants is surfaced by validateIndexConsistency; skip repair here
}
if (report.healthy) continue
if (report.invariants.some((i) => !i.holds && i.heal === 'rebuild')) {
prodLog.warn(
`[Brainy] repairIndex(): provider '${report.provider}' has a failing invariant ` +
`requiring a rebuild — reconciling its derived state from canonical.`
)
await p.rebuild()
}
}
// detectAndRepairCorruption() above rebuilt the derived indexes from
// canonical, so any adopt-forward degraded ids and a non-fatal init
// rebuild failure are now reconciled — clear the queryable degraded state