feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door
All checks were successful
CI / Node 22 (push) Successful in 12m19s
CI / Node 24 (push) Successful in 12m16s
CI / Integration + conformance (Node 22) (push) Successful in 18m41s
CI / Bun (latest) (push) Successful in 12m20s

The read gate stops consulting the unnamed isReady() boolean: every provider
may expose healthReport() (sync, O(1), composed from exact ledgers —
HealthReport with a monotonic generation, per-invariant source
ledger|deep|unledgered, missing {count, sample}), and one readiness
authority (assessProviderHealth) derives the verdict. Unledgered families
are UNKNOWN — never healthy, never broken; a report that throws is a loud
not-ready, never a shrug. Reads at the four index choke points refuse with
the typed NotReady errors, narrated once per (provider, generation) — a
read NEVER starts a store walk:

- the first-read lazy build retires (open builds instead, regardless of
  size — the ≥10k deferral and the "lazy loading on first query" branch go;
  disableAutoRebuild is re-meant honestly in its docs);
- the verify*Live read-path rebuild triggers retire (refuse-or-serve);
- the read-time consistency probe that could launch a dark rebuild from an
  ordinary find() retires;
- repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the
  one explicit door: rebuilds the named leg unconditionally and reports
  rebuilt per family; bare repairIndex() stays report-driven.

test(lifecycle): the biography lane — a store's whole life, refereed

tests/lifecycle/: an independent shadow model referees every read after
every chapter (founding, a working day, clean restart, crash, repair,
second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and
are marked .fails as a release-blocking finding (the kill-matrix
convention): after a crash + adopt reopen the metadata index computes its
'catchup' watermark verdict and nothing consumes it — find() serves the
pre-crash index while canonical and counts recover. The catchup wiring is
the cure; a passing .fails will force the marker's removal. The lane runs
in the integration gate (config + coverage guard).
This commit is contained in:
David Snelling 2026-08-24 12:45:51 -07:00
parent a8b5ca0c8f
commit f8f64780b1
19 changed files with 2160 additions and 652 deletions

File diff suppressed because it is too large Load diff

View file

@ -269,6 +269,10 @@ export type { FamilyStamp, StampMembers, StampVerdict } from './db/familyStamp.j
export { isVersionedIndexProvider } from './plugin.js'
export type { VersionedIndexProvider } from './plugin.js'
export type { ProviderInvariantReport, InvariantResult, InvariantHeal } from './plugin.js'
// The named, synchronous, O(1) health-report contract (the read gate's ONLY
// source of truth for "can I serve right now") — see HealthReport's
// derivation laws in plugin.ts.
export type { HealthReport, LedgerInvariantResult, InvariantSource } from './plugin.js'
// Optional provider self-report of outstanding background maintenance work
// (compaction, deferred writes, etc.) — the payload type for
// brain.maintenanceDebt(). See the measure-only-what-you-track contract on

View file

@ -171,6 +171,66 @@ export interface ProviderInvariantReport {
durationMs: number
}
/**
* @description Where a {@link LedgerInvariantResult} verdict came from:
* - `'ledger'` decided from an exact, durable ledger (a real count, not a sample).
* - `'deep'` decided by a full/expensive scan (the `validateInvariants()` diagnostic path only).
* - `'unledgered'` this family has no ledger yet; the verdict is UNKNOWN, never healthy and never broken.
*/
export type InvariantSource = 'ledger' | 'deep' | 'unledgered'
/**
* @description One invariant verdict inside a {@link HealthReport}. Extends
* {@link InvariantResult} with the provenance of the verdict ({@link InvariantSource})
* and, for a failing set-membership invariant, an exact count plus a capped sample
* of the diverging ids a VERDICT, never a dump. `sample` MUST be capped at 16 ids;
* `count` is the exact number even when `sample` is truncated.
*/
export interface LedgerInvariantResult extends InvariantResult {
/** Provenance of this verdict — see {@link InvariantSource}. */
source: InvariantSource
/** Exact count of diverging/missing items plus a capped (≤16 ids) sample. Present only on a failing set-membership invariant. */
missing?: { count: number; sample: string[] }
}
/**
* @description The NAMED, SYNCHRONOUS, O(1) health report a provider exposes via
* {@link MetadataIndexProvider.healthReport} / {@link GraphIndexProvider.healthReport} /
* {@link VectorIndexProvider.healthReport}. This is the read gate's ONLY source of
* truth for "can I serve right now" it replaces sampled self-probes and the
* unnamed `isReady()` latch with an exact, ledger-derived verdict.
*
* Derivation laws (a provider MUST honor these; brainy's read gate assumes them):
* - `healthy` = every VERIFIED invariant in {@link invariants} holds. An invariant
* whose family is named in {@link unledgered} is NEVER counted toward `healthy`
* either way it is unknown, not passing.
* - `serving` = no verified invariant in {@link invariants} FAILS with `heal: 'rebuild'`.
* A failure with `heal: 'repair'` or `heal: 'none'` is degraded-but-serving
* `serving` stays `true`. Only a `'rebuild'`-grade failure makes `serving` `false`.
* - `validateInvariants()` remains the async DEEP diagnostic (full scans allowed,
* `source: 'deep'` results); `healthReport()` MUST be synchronous, O(1) from
* exact ledgers/counters, and MUST NOT throw for a well-formed provider a
* provider that cannot produce a safe verdict reports it as a failing invariant,
* it does not throw (a throw is read by the gate as a CONTRACT VIOLATION, not as
* "unknown").
*/
export interface HealthReport extends ProviderInvariantReport {
/**
* Monotonic per provider: bumps on every ledger mutation and every rebuild
* boundary. Consumers (the read gate's narration dedup, external callers) may
* cache a verdict per generation.
*/
generation: number
/** Each checked invariant, with provenance — see {@link LedgerInvariantResult}. */
invariants: LedgerInvariantResult[]
/**
* Families with no ledger yet. NAMED here so an operator can see what is not
* yet tracked NEVER counted as healthy (they are not verified) and NEVER
* counted as broken (there is nothing to fail).
*/
unledgered: string[]
}
/**
* @description A provider's self-report of its own outstanding background
* maintenance work (compaction, deferred writes, a build-newverifyswap in
@ -266,6 +326,20 @@ export interface MetadataIndexProvider {
*/
validateInvariants?(): Promise<ProviderInvariantReport>
/**
* @description OPTIONAL. The named, SYNCHRONOUS, O(1) health verdict this
* provider derives from its own exact ledgers see {@link HealthReport} for
* the full derivation laws. MUST NOT perform I/O and MUST NOT throw for a
* well-formed provider (brainy treats a throw as a CONTRACT VIOLATION, never
* as "unknown"). When present, brainy's read gate (`assessProviderHealth()`)
* reads THIS instead of `isReady()` / size heuristics: `serving` decides
* whether reads may proceed; a `false` refuses the read loudly rather than
* triggering a rebuild. Absent the gate falls back to `isReady?()` / the
* size heuristic (this train's JS built-in providers stay on that interim
* path).
*/
healthReport?(): HealthReport
/**
* @description OPTIONAL. A native provider returns true from the moment its
* `init()` detects a large epoch-drift until its background
@ -462,6 +536,20 @@ export interface GraphIndexProvider {
*/
validateInvariants?(): Promise<ProviderInvariantReport>
/**
* @description OPTIONAL. The named, SYNCHRONOUS, O(1) health verdict this
* provider derives from its own exact ledgers see {@link HealthReport} for
* the full derivation laws. MUST NOT perform I/O and MUST NOT throw for a
* well-formed provider (brainy treats a throw as a CONTRACT VIOLATION, never
* as "unknown"). When present, brainy's read gate (`assessProviderHealth()`)
* reads THIS instead of `isReady()` / size heuristics: `serving` decides
* whether reads may proceed; a `false` refuses the read loudly rather than
* triggering a rebuild. Absent the gate falls back to `isReady?()` / the
* size heuristic (this train's JS built-in providers stay on that interim
* path).
*/
healthReport?(): HealthReport
/**
* @description OPTIONAL eager cold-load. Called once during brain init AFTER
* the metadata provider's `init()` (so the id-mapper is hydrated; a native int
@ -1225,6 +1313,20 @@ export interface VectorIndexProvider {
*/
validateInvariants?(): Promise<ProviderInvariantReport>
/**
* @description OPTIONAL. The named, SYNCHRONOUS, O(1) health verdict this
* provider derives from its own exact ledgers see {@link HealthReport} for
* the full derivation laws. MUST NOT perform I/O and MUST NOT throw for a
* well-formed provider (brainy treats a throw as a CONTRACT VIOLATION, never
* as "unknown"). When present, brainy's read gate (`assessProviderHealth()`)
* reads THIS instead of `isReady()` / size heuristics: `serving` decides
* whether reads may proceed; a `false` refuses the read loudly rather than
* triggering a rebuild. Absent the gate falls back to `isReady?()` / the
* size heuristic (this train's JS built-in providers stay on that interim
* path).
*/
healthReport?(): HealthReport
/**
* @description OPTIONAL. A native provider returns true from the moment its
* `init()` detects a large epoch-drift until its background

View file

@ -1816,10 +1816,16 @@ export interface BrainyConfig {
| StorageAdapter
/**
* Disable the automatic index rebuild check during `init()`. By default
* Brainy auto-decides from dataset size: small datasets rebuild missing
* indexes inline, large datasets rebuild lazily on first query. Set `true`
* only when an operator wants full manual control via `repairIndex()`.
* RE-MEANT (the health-gate contract): `init()` (open) always verifies the
* durable generation of every derived index, and a needed rebuild ALWAYS
* runs at open it is never deferred to the first read, regardless of
* dataset size or this flag. There is no first-query lazy-build path
* anymore: a read that finds a provider not serving throws a typed
* `*NotReadyError` rather than building anything (see
* `assessProviderHealth` / the read gate in `brainy.ts`). Setting this
* `true` no longer defers index construction to the first query it has
* no effect on WHEN a needed rebuild runs. Full manual control over
* rebuilds remains available via `repairIndex({ rebuild: [...] })`.
*/
disableAutoRebuild?: boolean

View file

@ -13,13 +13,28 @@
* `size()` or `isInitialized`. When `isReady()` is absent, callers must fall back
* to a KNOWN-ITEM PROBE (a real search/lookup that must return a known-present
* datum) before trusting an empty result never a `size()` proxy.
*
* {@link assessProviderHealth} is the NEWER, PREFERRED authority: it reads a
* provider's NAMED, synchronous, O(1) {@link import('../plugin.js').HealthReport}
* when one is exposed, and falls back to this file's `isReady()` classifier only
* when the provider does not (yet) expose a health report. Read paths in
* `brainy.ts` call `assessProviderHealth` exclusively `assessIndexReadiness`
* stays exported for the other call sites (`storage/baseStorage.ts`) and for the
* fallback branch inside `assessProviderHealth` itself.
*/
import type { HealthReport } from '../plugin.js'
/** A provider that MAY expose the honest cold-load readiness signal. */
export interface MaybeReadyProvider {
isReady?: () => boolean
}
/** A provider that MAY expose the named, synchronous, O(1) health report. */
export interface MaybeHealthReportingProvider {
healthReport?: () => HealthReport
}
/** Three-valued honest-readiness verdict. */
export type IndexReadiness = 'ready' | 'not-ready' | 'unknown'
@ -36,3 +51,105 @@ export function assessIndexReadiness(provider: unknown): IndexReadiness {
if (p == null || typeof p.isReady !== 'function') return 'unknown'
return p.isReady() ? 'ready' : 'not-ready'
}
/**
* @description Which signal {@link assessProviderHealth} actually consulted to
* produce its verdict surfaced so callers can narrate (and tests can pin) how
* a provider was judged, not just what the judgment was.
* - `'health-report'` the provider's `healthReport()` was called (the authority).
* - `'is-ready'` no `healthReport()`; fell back to the provider's `isReady()`.
* - `'size-heuristic'` no `healthReport()` and no `isReady()`; caller must keep its own size-based heuristic.
* - `'none'` there was no provider to assess (`null`/`undefined`).
*/
export type ProviderHealthVia = 'health-report' | 'is-ready' | 'size-heuristic' | 'none'
/** The result of {@link assessProviderHealth}. */
export interface ProviderHealthAssessment {
/** The honest readiness verdict — see {@link IndexReadiness}. */
readiness: IndexReadiness
/** The provider's raw {@link HealthReport}, when one was obtained; `null` otherwise. */
report: HealthReport | null
/** Which signal produced the verdict — see {@link ProviderHealthVia}. */
via: ProviderHealthVia
/** Human-readable reasons: named failing invariants (with `heal`), unledgered families, or the fallback-path explanation. Empty when the provider is healthy and ready. */
reasons: string[]
}
/**
* @description THE read-gate authority. Prefers a provider's NAMED,
* synchronous, O(1) {@link HealthReport} over the older `isReady()` / size
* heuristics; falls back to {@link assessIndexReadiness}'s semantics only when
* a provider does not (yet) expose `healthReport()`.
*
* Derivation:
* - `healthReport()` present call it (wrapped in try/catch). A THROW is a
* CONTRACT VIOLATION, not "unknown": returns `readiness: 'not-ready'`,
* `via: 'health-report'`, and a reason naming the throw never swallowed
* into `'unknown'`.
* - Otherwise `readiness = report.serving ? 'ready' : 'not-ready'`; `reasons`
* names every invariant with `holds: false` (with its `heal`), plus an
* `unledgered: [...]` line when {@link HealthReport.unledgered} is non-empty.
* UNLEDGERED IS UNKNOWN: an unledgered family never flips a serving provider
* to not-ready, and never flips a not-serving provider to ready `serving`
* is always the provider's own verdict, verbatim.
* - No `healthReport()` fall back to {@link assessIndexReadiness}'s semantics:
* `via: 'is-ready'` when `isReady()` exists, `via: 'size-heuristic'` when
* neither hook exists (caller must keep its own size-based heuristic),
* `via: 'none'` when there is no provider at all.
* @param provider - Any index provider (vector / graph / metadata) or `null`/`undefined`.
*/
export function assessProviderHealth(provider: unknown): ProviderHealthAssessment {
const p = provider as (MaybeHealthReportingProvider & MaybeReadyProvider) | null | undefined
if (p == null) {
return { readiness: 'unknown', report: null, via: 'none', reasons: ['no provider to assess'] }
}
if (typeof p.healthReport === 'function') {
let report: HealthReport
try {
report = p.healthReport()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
readiness: 'not-ready',
report: null,
via: 'health-report',
reasons: [`healthReport() threw: ${message} — a health-report throw is a contract violation, never read as healthy`]
}
}
const reasons: string[] = []
for (const invariant of report.invariants) {
if (!invariant.holds) {
reasons.push(`${invariant.name} (heal:${invariant.heal}): ${invariant.detail}`)
}
}
if (report.unledgered.length > 0) {
reasons.push(`unledgered: ${report.unledgered.join(', ')}`)
}
return {
readiness: report.serving ? 'ready' : 'not-ready',
report,
via: 'health-report',
reasons
}
}
const readiness = assessIndexReadiness(p)
if (readiness === 'unknown') {
return {
readiness,
report: null,
via: 'size-heuristic',
reasons: ['provider exposes neither healthReport() nor isReady() — falling back to the size heuristic']
}
}
return {
readiness,
report: null,
via: 'is-ready',
reasons: readiness === 'not-ready' ? ['isReady() returned false'] : []
}
}