diff --git a/src/brainy.ts b/src/brainy.ts index 6ebce2fd..db332e18 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -14221,16 +14221,29 @@ export class Brainy implements BrainyInterface { const report = await fn.call(provider) if (report && Array.isArray(report.invariants)) reports.push(report) } catch (err) { + // ONE CONTRACT FOR A THROWING PROBE, both engines: a probe that throws + // is `heal: 'none'` with the error in `detail` — flakiness can never + // buy a rebuild, and a thrown check never changes `serving` (the + // provider's serving verdict is composed by the provider, not inferred + // from a probe that failed to run). This catch used to synthesize + // `heal: 'rebuild'` — the read-triggered dark-rebuild lever one + // transient exception away — while the native composer said 'none'; + // two components disagreeing on what a throw means is how a flaky + // probe became an outage. `healthy: false` stays: an unrunnable probe + // is a named, loud, unverified state, never a clean bill. + const name = typeof (provider as { name?: string })?.name === 'string' + ? (provider as { name: string }).name + : 'unknown' reports.push({ - provider: 'unknown', + provider: name, healthy: false, - serving: false, + serving: true, invariants: [ { name: 'validate-invariants-threw', holds: false, detail: `validateInvariants() threw (contract violation — it must never throw): ${(err as Error).message}`, - heal: 'rebuild' + heal: 'none' } ], checkedAt: Date.now(), diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts index ce3236e9..78e310f3 100644 --- a/src/types/brainy.types.ts +++ b/src/types/brainy.types.ts @@ -1200,10 +1200,23 @@ export interface RelateManyParams { */ export interface RepairFamilyReport { family: string + /** The family was actually examined (false = skipped; see `skipped`/`reason`). */ checked: boolean + /** Items re-posted / corrected in place — the incremental heal count. */ healed: number + /** + * What the check found missing or divergent, when it can name it: an exact + * count plus a capped sample of ids (never the whole list — a report is a + * verdict, not a dump). Absent when the family has nothing to name. + */ + missing?: { count: number; sample: string[] } + /** A full generational rebuild of this family ran (as opposed to an incremental heal). */ + rebuilt?: boolean detail?: string + /** Why the family was not checked (`checked: false`). */ skipped?: string + /** Why the outcome is what it is when neither `detail` nor `skipped` says it. */ + reason?: string } /** The full receipt returned by repairIndex(). */ diff --git a/tests/unit/validate-invariants-delegation.test.ts b/tests/unit/validate-invariants-delegation.test.ts index 45e12ccd..a5def81f 100644 --- a/tests/unit/validate-invariants-delegation.test.ts +++ b/tests/unit/validate-invariants-delegation.test.ts @@ -77,6 +77,31 @@ describe('validateIndexConsistency delegates to provider validateInvariants() (P delete brain.index.validateInvariants }) + it('ONE CONTRACT FOR A THROWING PROBE: heal is none (flakiness never buys a rebuild) and serving is not withheld', async () => { + // The probe that fails to RUN must never be read as "the index is broken, + // rebuild it" — that synthesized heal:'rebuild' was the dark-rebuild lever + // one transient exception away, and the native composer already said + // 'none' for the same event. Both engines now agree: named, loud, + // unverified — and never a rebuild, never a withheld serve. + brain.index.validateInvariants = async () => { throw new Error('transient: mmap window busy') } + const v = await brain.validateIndexConsistency() + const thrown = v.providers?.find((p: ProviderInvariantReport) => + p.invariants.some((i) => i.name === 'validate-invariants-threw') + ) + expect(thrown).toBeDefined() + expect(thrown!.healthy).toBe(false) + expect(thrown!.serving).toBe(true) + const inv = thrown!.invariants.find((i) => i.name === 'validate-invariants-threw')! + expect(inv.holds).toBe(false) + expect(inv.heal).toBe('none') + expect(inv.detail).toMatch(/transient: mmap window busy/) + // No provider report in the set recommends a rebuild for this event. + expect( + v.providers!.flatMap((p: ProviderInvariantReport) => p.invariants).some((i) => i.heal === 'rebuild') + ).toBe(false) + delete brain.index.validateInvariants + }) + it('providers without validateInvariants() are omitted (JS baseline unchanged)', async () => { const v = await brain.validateIndexConsistency() expect(v.providers).toBeUndefined()