diff --git a/.changeset/external-validation-checkonboot-enforced.md b/.changeset/external-validation-checkonboot-enforced.md new file mode 100644 index 0000000000..a6e041a788 --- /dev/null +++ b/.changeset/external-validation-checkonboot-enforced.md @@ -0,0 +1,53 @@ +--- +"@objectstack/runtime": patch +--- + +fix(runtime): honour `datasource.external.validation.checkOnBoot` in the boot validation sweep (#13037) + +`checkOnBoot` has been declared on `DatasourceSchema` — with `.default(true)` — +since the `external.validation` block was written, and **nothing read it**. Its +two block-mates are read (`onMismatch` by `resolveOnMismatch()`, +`checkIntervalMs` by `scheduleDriftChecks()`), which is what made the gap legible +rather than a whole-block miss: `ExternalValidationPlugin.start` hooked +`kernel:ready` and called `runValidation(ctx)` with no condition on it. + +So an author who wrote `validation: { checkOnBoot: false }` and left `onMismatch` +at its default got the boot sweep anyway, and a measured mismatch threw +`ExternalSchemaMismatchError` and **aborted boot** — the exact outcome the key +reads as opting out of. The `.default(true)` made it worse than an ignored key: +the knob is materialized into every parse output, so a dead setting is +byte-identical to an honoured one in stored and serialized datasources, and +neither an author, an AI author, nor someone reading the metadata store could +tell which one they had. + +Maintainer ruling 2026-08-29 — ADR-0049 disposition **enforce, not remove**: + +- `checkOnBoot: false` ⇒ that datasource is skipped by the `kernel:ready` sweep. + No `onMismatch` policy is applied to its rows, so a measured mismatch on it can + no longer abort startup; its unreachable-remote rows raise no boot warning; and + its objects are not counted in the all-clear. The skip is logged once, naming + the datasources and stating that the verdict beside it covers the remaining + ones only. +- `checkOnBoot: true` or absent ⇒ today's behaviour, unchanged — a measured + mismatch still throws `ExternalSchemaMismatchError` and aborts boot under the + default `onMismatch: 'fail'`. + +The gate is **per datasource**, because the sweep is whole-farm and the key is +per-source: in one boot, an opted-out datasource does not suppress another +datasource's abort. Every uncertainty resolves towards running the check — an +absent key, an unparsed or legacy stored row, a managed datasource with no +`external` block, and a definition the metadata service could not read are all +validated, never inferred to have opted out. + +**Scope, pinned at the ruling: the boot step only.** `scheduleDriftChecks()` and +its `external.validation.checkIntervalMs` read point stay independent — a +datasource that opts out of the boot check keeps whatever background drift +checking it armed. The two keys answer different questions ("gate my startup on +this" versus "watch this while I run"), and both the code comment and a test hold +that boundary. + +Not a contract-face change: no schema, no key, and no accepted spelling moves. +`checkonboot` and `validateonboot` remain what they already were — entries in the +`strictObject` rejection table that refuse the misspelling and prescribe +`checkOnBoot` — so `checkOnBoot` is the single authorable spelling and the gate +has a single read point. diff --git a/packages/runtime/src/external-validation-checkonboot.test.ts b/packages/runtime/src/external-validation-checkonboot.test.ts new file mode 100644 index 0000000000..e8e2b3939c --- /dev/null +++ b/packages/runtime/src/external-validation-checkonboot.test.ts @@ -0,0 +1,409 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13037] `datasource.external.validation.checkOnBoot` — declared with + * `.default(true)` since the block was written, and read by NOTHING until this + * card. An author who wrote `checkOnBoot: false` and left `onMismatch` at its + * default still got the boot sweep, and a measured mismatch still threw + * `ExternalSchemaMismatchError` and aborted startup — the exact outcome the key + * reads as opting out of. The `.default(true)` made it worse than an ignored + * key: it materializes into every parse output, so a dead knob is byte-identical + * to an honoured one in stored and serialized datasources. + * + * Maintainer ruling 2026-08-29 (verbatim: 「同意」) — ADR-0049 disposition + * **enforce, not remove**, with the scope pinned at the ruling: the gate covers + * the **boot step only**. These tests pin both directions and the scope, since + * a gate that fires in only one direction is the same defect wearing a fix. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +// `.js` extension deliberately: this package's `typecheck` excludes `*.test.ts`, +// but the shrink-only TEST_DEBT ratchet (`pnpm check:type-check-debt`) does read +// this layer under `moduleResolution: nodenext`, where an extension-less +// relative import is a TS2835 — measured, and it moved the frozen count 217 → +// 218 before this extension was added. +import { ExternalValidationPlugin } from './external-validation-plugin.js'; +import { ExternalSchemaMismatchError, type SchemaDiffEntry } from '@objectstack/spec/shared'; +import { DatasourceSchema } from '@objectstack/spec/data'; + +type Row = { ok: boolean; datasource: string; object: string; diffs: SchemaDiffEntry[] }; + +function makeCtx(services: Record) { + const warnings: unknown[][] = []; + const infos: unknown[][] = []; + const ctx = { + getService: (name: string): T => { + if (name in services) return services[name] as T; + throw new Error(`service '${name}' not registered`); + }, + registerService: vi.fn(), + hook: vi.fn(), + trigger: vi.fn(), + logger: { + debug: vi.fn(), + info: (...a: unknown[]) => infos.push(a), + warn: (...a: unknown[]) => warnings.push(a), + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + return { ctx, warnings, infos }; +} + +/** A real measured mismatch — the kind that reaches the `onMismatch` policy. */ +const mismatch: SchemaDiffEntry[] = [ + { kind: 'type_mismatch', remoteName: 'fact_orders', column: 'amount', expected: 'number', actual: 'text', severity: 'error' }, +]; + +/** [#11166] The indeterminate row: the remote could not be read at all. */ +const unreachable: SchemaDiffEntry[] = [ + { kind: 'unreachable', remoteName: 'fact_orders', actual: 'connect ECONNREFUSED 10.0.0.5:5432', severity: 'error' }, +]; + +const sweep = (rows: Row[]) => ({ + validateAll: async () => ({ ok: rows.every((r) => r.ok), results: rows }), +}); + +/** + * A metadata service answering per-datasource definitions, counting its reads. + * The call count is asserted on directly — the gate resolves a per-datasource + * key over a whole-farm report, and "once per datasource" rather than "once per + * row" is a property worth pinning, not an implementation detail. + */ +function metadataOf(defs: Record) { + const calls: string[] = []; + return { + calls, + service: { + get: async (type: string, name: string) => { + calls.push(`${type}:${name}`); + return defs[name]; + }, + }, + }; +} + +const withCheckOnBoot = (checkOnBoot: boolean, onMismatch: 'fail' | 'warn' | 'ignore' = 'fail') => ({ + schemaMode: 'external', + external: { validation: { onMismatch, checkOnBoot } }, +}); + +const skipLine = (infos: unknown[][]) => infos.find((i) => String(i[0]).includes('SKIPPED')); + +describe('checkOnBoot: false — the datasource is skipped by the kernel:ready sweep (#13037)', () => { + /** + * ⭐ The card's actual harm, driven end to end. Asserting merely that + * validation "was not run" would leave the interesting half untested: what + * the author is opting out of is not the work, it is the ABORT. + */ + it('a MEASURED mismatch cannot abort boot through a checkOnBoot:false datasource', async () => { + const meta = metadataOf({ warehouse: withCheckOnBoot(false) }); + const { ctx, warnings, infos } = makeCtx({ + 'external-datasource': sweep([ + { ok: false, datasource: 'warehouse', object: 'wh_order', diffs: mismatch }, + ]), + metadata: meta.service, + }); + + // Boot completes. Under `onMismatch: 'fail'` this same row aborts boot when + // checkOnBoot is true — pinned in the sibling describe below. + await expect(new ExternalValidationPlugin().runValidation(ctx)).resolves.toBeUndefined(); + + // And it was skipped, not merely tolerated: no drift warning was logged for + // it either, and the skip is stated to the operator by name. + expect(warnings.some((w) => String(w[0]).includes('drift'))).toBe(false); + expect(skipLine(infos)?.[1]).toMatchObject({ datasources: ['warehouse'], objectsSkipped: 1 }); + }); + + it('its objects are not counted in the all-clear — the verdict covers the gated datasources only', async () => { + const meta = metadataOf({ + warehouse: withCheckOnBoot(false), + ledger: withCheckOnBoot(true), + }); + const { ctx, infos } = makeCtx({ + 'external-datasource': sweep([ + { ok: true, datasource: 'warehouse', object: 'wh_order', diffs: [] }, + { ok: true, datasource: 'warehouse', object: 'wh_item', diffs: [] }, + { ok: true, datasource: 'ledger', object: 'gl_entry', diffs: [] }, + ]), + metadata: meta.service, + }); + + await new ExternalValidationPlugin().runValidation(ctx); + + const allClear = infos.find((i) => String(i[0]).includes('match their remote schema')); + expect(allClear?.[1]).toMatchObject({ objects: 1 }); + expect(skipLine(infos)?.[1]).toMatchObject({ datasources: ['warehouse'], objectsSkipped: 2 }); + }); + + /** + * [#11166]'s loud unreachable warning is part of the boot gate, so it is part + * of what `checkOnBoot: false` opts out of. Skipped means skipped — an author + * who took the boot check off their datasource should not be told at every + * startup that the boot check could not read it. + */ + it('does not raise the unreachable-remote warning for a skipped datasource', async () => { + const meta = metadataOf({ warehouse: withCheckOnBoot(false) }); + const { ctx, warnings } = makeCtx({ + 'external-datasource': sweep([ + { ok: false, datasource: 'warehouse', object: 'wh_order', diffs: unreachable }, + ]), + metadata: meta.service, + }); + + await expect(new ExternalValidationPlugin().runValidation(ctx)).resolves.toBeUndefined(); + expect(warnings.some((w) => String(w[0]).includes('could not be validated'))).toBe(false); + }); +}); + +describe('checkOnBoot: true / absent — today\'s behaviour, unchanged (#13037)', () => { + it('an explicit checkOnBoot:true still aborts boot on a measured mismatch', async () => { + const meta = metadataOf({ warehouse: withCheckOnBoot(true) }); + const { ctx } = makeCtx({ + 'external-datasource': sweep([ + { ok: false, datasource: 'warehouse', object: 'wh_order', diffs: mismatch }, + ]), + metadata: meta.service, + }); + + // ADR-0112 envelope, not a bare `toThrow()`: the code and status are the + // contract, and a bare throw assertion passes for any accidental `Error`. + await expect(new ExternalValidationPlugin().runValidation(ctx)).rejects.toMatchObject({ + code: 'EXTERNAL_SCHEMA_MISMATCH', + status: 503, + datasource: 'warehouse', + }); + }); + + it('an ABSENT checkOnBoot still aborts boot — the schema default is true', async () => { + const meta = metadataOf({ + // No `checkOnBoot` key at all: a legacy stored row, or a definition that + // never went through the parse that materializes the default. + warehouse: { schemaMode: 'external', external: { validation: { onMismatch: 'fail' } } }, + }); + const { ctx } = makeCtx({ + 'external-datasource': sweep([ + { ok: false, datasource: 'warehouse', object: 'wh_order', diffs: mismatch }, + ]), + metadata: meta.service, + }); + + await expect(new ExternalValidationPlugin().runValidation(ctx)).rejects.toBeInstanceOf( + ExternalSchemaMismatchError, + ); + }); + + /** + * The gate must be silent when nobody opted out — a new line on every healthy + * boot would be a behaviour change of its own, and the ruling asked for this + * branch to stay byte-identical. + */ + it('emits no skip line when no datasource opts out', async () => { + const meta = metadataOf({ warehouse: withCheckOnBoot(true) }); + const { ctx, infos } = makeCtx({ + 'external-datasource': sweep([ + { ok: true, datasource: 'warehouse', object: 'wh_order', diffs: [] }, + ]), + metadata: meta.service, + }); + + await new ExternalValidationPlugin().runValidation(ctx); + expect(skipLine(infos)).toBeUndefined(); + expect(infos.some((i) => String(i[0]).includes('match their remote schema'))).toBe(true); + }); + + /** + * Every uncertainty resolves towards RUNNING the check. A definition that + * cannot be read must never be inferred to have opted out — that would turn a + * transient metadata outage into a silently ungated boot, which is the whole + * failure class this card is about, inverted. + */ + it('validates — does not skip — when the datasource definition cannot be read', async () => { + const { ctx } = makeCtx({ + 'external-datasource': sweep([ + { ok: false, datasource: 'warehouse', object: 'wh_order', diffs: mismatch }, + ]), + metadata: { get: async () => { throw new Error('metadata store unreachable'); } }, + }); + + await expect(new ExternalValidationPlugin().runValidation(ctx)).rejects.toBeInstanceOf( + ExternalSchemaMismatchError, + ); + }); +}); + +describe('the gate is PER DATASOURCE, not global (#13037)', () => { + /** + * ⭐ The assertion the ruling actually turns on. The sweep is whole-farm and + * the key is per-source, so a single-datasource test cannot tell a per-source + * gate apart from a global kill switch: both pass it. This one separates them + * — one datasource opts out and mismatches, another does not and mismatches, + * and boot must still abort FOR THE SECOND ONE. + */ + it('a checkOnBoot:false datasource does not suppress another datasource\'s abort', async () => { + const meta = metadataOf({ + warehouse: withCheckOnBoot(false), + ledger: withCheckOnBoot(true), + }); + const { ctx, infos } = makeCtx({ + 'external-datasource': sweep([ + { ok: false, datasource: 'warehouse', object: 'wh_order', diffs: mismatch }, + { ok: false, datasource: 'ledger', object: 'gl_entry', diffs: mismatch }, + ]), + metadata: meta.service, + }); + + const err = await new ExternalValidationPlugin().runValidation(ctx).then( + () => { throw new Error('boot was expected to abort on the ledger mismatch'); }, + (e: unknown) => e as ExternalSchemaMismatchError, + ); + + expect(err).toBeInstanceOf(ExternalSchemaMismatchError); + expect(err.code).toBe('EXTERNAL_SCHEMA_MISMATCH'); + expect(err.status).toBe(503); + // ⭐ It aborted BECAUSE OF `ledger`. Asserting only "it threw" would pass on + // a gate wired backwards, which would have thrown for `warehouse` instead. + expect(err.datasource).toBe('ledger'); + expect(err.object).toBe('gl_entry'); + // And the opted-out one really was dropped rather than merely out-raced. + expect(skipLine(infos)?.[1]).toMatchObject({ datasources: ['warehouse'] }); + }); + + it('reads each datasource definition once per sweep, not once per row', async () => { + const meta = metadataOf({ + warehouse: withCheckOnBoot(false), + ledger: withCheckOnBoot(true), + }); + const { ctx } = makeCtx({ + 'external-datasource': sweep([ + { ok: true, datasource: 'warehouse', object: 'wh_a', diffs: [] }, + { ok: true, datasource: 'warehouse', object: 'wh_b', diffs: [] }, + { ok: true, datasource: 'ledger', object: 'gl_a', diffs: [] }, + { ok: true, datasource: 'ledger', object: 'gl_b', diffs: [] }, + ]), + metadata: meta.service, + }); + + await new ExternalValidationPlugin().runValidation(ctx); + expect(meta.calls.sort()).toEqual(['datasource:ledger', 'datasource:warehouse']); + }); +}); + +describe('the value read is the PARSED one, and there is only one spelling of it (#13037)', () => { + const authored = (validation: Record) => ({ + name: 'warehouse', + driver: 'postgres' as const, + config: { host: 'db.internal', database: 'warehouse' }, + schemaMode: 'external' as const, + external: { validation }, + }); + + /** + * ⭐ End to end through the real schema: an author document is parsed by + * `DatasourceSchema`, and the PARSED definition — the shape the metadata + * service hands back — is what the gate is handed. This is what stops the + * read point drifting to raw author input, which would miss both the + * `.default(true)` materialization and any future conversion-layer rewrite. + */ + it('a parsed checkOnBoot:false datasource is honoured by the boot gate', async () => { + const parsed = DatasourceSchema.parse(authored({ onMismatch: 'fail', checkOnBoot: false })); + expect(parsed.external?.validation?.checkOnBoot).toBe(false); + + const meta = metadataOf({ warehouse: parsed }); + const { ctx, infos } = makeCtx({ + 'external-datasource': sweep([ + { ok: false, datasource: 'warehouse', object: 'wh_order', diffs: mismatch }, + ]), + metadata: meta.service, + }); + + await expect(new ExternalValidationPlugin().runValidation(ctx)).resolves.toBeUndefined(); + expect(skipLine(infos)?.[1]).toMatchObject({ datasources: ['warehouse'] }); + }); + + it('a parsed datasource that omits the key carries the materialized default and is validated', async () => { + const parsed = DatasourceSchema.parse(authored({ onMismatch: 'fail' })); + // The `.default(true)` the card names: the knob is present in every parse + // output, which is exactly why an unread one was indistinguishable from an + // honoured one. + expect(parsed.external?.validation?.checkOnBoot).toBe(true); + + const meta = metadataOf({ warehouse: parsed }); + const { ctx } = makeCtx({ + 'external-datasource': sweep([ + { ok: false, datasource: 'warehouse', object: 'wh_order', diffs: mismatch }, + ]), + metadata: meta.service, + }); + + await expect(new ExternalValidationPlugin().runValidation(ctx)).rejects.toBeInstanceOf( + ExternalSchemaMismatchError, + ); + }); + + /** + * ⭐ `checkonboot` and `validateonboot` appear in this block's `strictObject` + * table, and both the card and the dispatch read that table as an alias FOLD + * — i.e. as two further authorable spellings the gate would have to honour or + * else deliver half the surface. Measured here: it is not a fold. The table + * feeds the `unrecognized_keys` REJECTION path only (`strict-object.ts`: "an + * alias runs only from the `unrecognized_keys` path"), so both spellings are + * refused at parse with a rename prescription, and `checkOnBoot` is the only + * spelling that ever reaches a reader. + * + * Pinned rather than merely noted, in both directions: if a real fold is ever + * added, this test reds and sends its author to the gate's read point instead + * of letting a second spelling silently become inert — which is the defect + * this whole card is about. ⛔ The answer to a red here is never a `??` chain + * in the consumer (AGENTS.md Prime Directive #12). + */ + it('`checkonboot` / `validateonboot` are REJECTED spellings, not folds — one key, one read point', () => { + for (const spelling of ['checkonboot', 'validateonboot']) { + const result = DatasourceSchema.safeParse(authored({ [spelling]: false })); + expect(result.success, `${spelling} must not parse`).toBe(false); + const issue = result.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue, `${spelling} must be refused as an unrecognized key`).toBeDefined(); + expect(issue!.message).toContain(spelling); + // The refusal carries the one spelling that works. + expect(issue!.message).toContain('checkOnBoot'); + } + + // Positive control for the probe above: the canonical spelling parses on + // the very same document, so the two failures are about the KEY and not + // about the fixture. + expect(DatasourceSchema.safeParse(authored({ checkOnBoot: false })).success).toBe(true); + }); +}); + +describe('scope: the gate covers the BOOT STEP ONLY (#13037)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + /** + * ⭐ The scope the maintainer pinned at the ruling, held mechanically rather + * than in prose: `checkOnBoot` and `checkIntervalMs` answer different + * questions ("gate my startup on this" vs "watch this while I run"), so a + * datasource that opted out of the boot check still gets the background drift + * checker it asked for. A future edit that extends the gate to + * `scheduleDriftChecks` fails here. + */ + it('checkOnBoot:false still arms the background drift checker it asked for', async () => { + const { ctx } = makeCtx({ + 'external-datasource': sweep([]), + metadata: { + list: async () => [ + { + name: 'warehouse', + schemaMode: 'external', + external: { validation: { onMismatch: 'fail', checkOnBoot: false, checkIntervalMs: 60_000 } }, + }, + ], + }, + }); + + const plugin = new ExternalValidationPlugin(); + await plugin.scheduleDriftChecks(ctx); + expect(vi.getTimerCount()).toBe(1); + plugin.destroy(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/runtime/src/external-validation-plugin.ts b/packages/runtime/src/external-validation-plugin.ts index 98ccac8bf6..287e24f984 100644 --- a/packages/runtime/src/external-validation-plugin.ts +++ b/packages/runtime/src/external-validation-plugin.ts @@ -154,6 +154,13 @@ interface DatasourceDef { external?: { validation?: { onMismatch?: 'fail' | 'warn' | 'ignore'; + /** + * [#13037] The BOOT gate's per-datasource opt-out — read by + * {@link bootCheckEnabled}, and by nothing else on purpose. The scope + * boundary the maintainer pinned when ruling this key ENFORCED (rather + * than retired) is stated at that function. + */ + checkOnBoot?: boolean; checkIntervalMs?: number; }; }; @@ -180,6 +187,12 @@ export interface ExternalSchemaDriftEvent { * - `warn` → logs the diff and continues, * - `ignore` → does nothing. * + * [#13037] A datasource that sets `external.validation.checkOnBoot: false` is + * skipped by this sweep entirely — no policy is applied to its rows, so no + * mismatch on it can abort boot. Its BACKGROUND drift checking is a separate + * policy and is unaffected; see {@link bootCheckEnabled} for the scope the + * maintainer pinned. + * * `onMismatch` governs MEASURED mismatches only. A row whose diffs are * `kind: 'unreachable'` (the remote could not be read, so validation was * indeterminate — see the kind's docblock in `@objectstack/spec/shared`) never @@ -261,6 +274,9 @@ export class ExternalValidationPlugin implements Plugin { } const metadata = safeGet(ctx, 'metadata'); + // [#13037] One definition read per datasource per sweep, shared by the + // `checkOnBoot` gate below and the `onMismatch` resolution after it. + const loadDef = createDatasourceDefLoader(metadata); let report: Awaited>; try { report = await svc.validateAll(); @@ -269,12 +285,38 @@ export class ExternalValidationPlugin implements Plugin { return; } - const failures = report.results.filter((r) => !r.ok); + // [#13037] Honour each datasource's `external.validation.checkOnBoot` + // BEFORE any verdict is drawn from its rows. The sweep is whole-farm and + // the key is per-datasource, so the opt-out can only be applied here, row + // by row — a datasource that set `false` is dropped, and every other + // datasource in the same boot is judged exactly as it was before. + // ⭐ Boot step only: `scheduleDriftChecks()` below is untouched by this. + const gated: SchemaValidationReportLike['results'] = []; + const skipped = new Set(); + for (const r of report.results) { + if (await bootCheckEnabled(loadDef, r.datasource)) gated.push(r); + else skipped.add(r.datasource); + } + if (skipped.size > 0) { + ctx.logger?.info?.( + '[external-validation] boot schema validation SKIPPED for datasource(s) that set ' + + '`external.validation.checkOnBoot: false` — their federated objects were NOT gated at ' + + 'boot, and no mismatch on them can abort startup. Any verdict logged below covers the ' + + 'REMAINING datasources only. Background drift checking is a separate policy ' + + '(`external.validation.checkIntervalMs`) and is unaffected.', + { + datasources: [...skipped].sort(), + objectsSkipped: report.results.length - gated.length, + }, + ); + } + + const failures = gated.filter((r) => !r.ok); if (failures.length === 0) { // [#6504] The all-clear is a UNIVERSAL claim, and this gate has no way to // make one when the object set it swept was itself known-partial. See // `announceAllClear`. - await announceAllClear(ctx, metadata, report.results.length); + await announceAllClear(ctx, metadata, gated.length); return; } @@ -310,7 +352,7 @@ export class ExternalValidationPlugin implements Plugin { // judge on what is present rather than on the producer's current shape: // only MEASURED diffs reach the onMismatch policy. if (schemaDiffs.length === 0) continue; - const mode = await resolveOnMismatch(metadata, r.datasource); + const mode = await resolveOnMismatch(loadDef, r.datasource); if (mode === 'ignore') continue; if (mode === 'warn') { ctx.logger?.warn?.('[external-validation] external schema drift', { @@ -334,6 +376,14 @@ export class ExternalValidationPlugin implements Plugin { * No-op when metadata can't be enumerated or no datasource opts in. Re-arming * (e.g. a second `kernel:ready`) first clears existing timers so intervals * don't accumulate. + * + * ⭐ [#13037] **`external.validation.checkOnBoot` does not reach here, by + * ruling.** The maintainer pinned that gate's scope to the BOOT STEP ONLY + * (2026-08-29): a datasource that set `checkOnBoot: false` still gets the + * background drift checker it asked for via `checkIntervalMs`, because the + * two keys answer different questions — "gate my startup on this" versus + * "watch this while I run". This read point was already independent and + * stays that way; ⛔ do not add a `checkOnBoot` condition below. */ async scheduleDriftChecks(ctx: PluginContext): Promise { // [#10772] The canonical hook, not the retained alias: `destroy()` is now @@ -515,16 +565,107 @@ export function createExternalValidationPlugin(): ExternalValidationPlugin { return new ExternalValidationPlugin(); } -async function resolveOnMismatch( +/** + * Reads one datasource definition per NAME per sweep, answering `undefined` for + * anything it could not read. + * + * [#13037] Introduced because the boot gate now asks the definition two + * questions — "does this datasource opt out of the boot check?" and, only for + * the rows that stayed, "what is its `onMismatch` policy?" — and asking twice + * would double the metadata reads for every mismatching row. Memoized per + * SWEEP, not per plugin instance: a second `kernel:ready` re-reads, so an + * operator who fixed a datasource between boots is not served a stale verdict. + * + * Swallowing the throw preserves `resolveOnMismatch`'s pre-existing contract + * (an unreadable definition falls back to the strict default) and gives the new + * gate the same safe direction: a definition nobody could read is validated, + * never silently skipped. + */ +function createDatasourceDefLoader( metadata: MetadataServiceLike | undefined, +): (datasource: string) => Promise { + const cache = new Map>(); + return (datasource: string) => { + let hit = cache.get(datasource); + if (!hit) { + hit = (async () => { + try { + return (await metadata?.get?.('datasource', datasource)) as DatasourceDef | undefined; + } catch { + return undefined; + } + })(); + cache.set(datasource, hit); + } + return hit; + }; +} + +/** + * [#13037] Does the BOOT sweep apply to this datasource? + * + * ## ⭐ Scope, pinned by the maintainer at the ruling (2026-08-29) + * + * **This gate covers the BOOT STEP ONLY.** `scheduleDriftChecks()` and its + * `external.validation.checkIntervalMs` read point stay INDEPENDENT of + * `checkOnBoot` — a datasource that opts out of the boot check keeps whatever + * background drift checking it armed, and arming one is not an opt back in. + * The two knobs sit in the same block and answer different questions: one is + * "gate my startup on this", the other is "watch this while I run". ⛔ Do not + * extend this predicate to `scheduleDriftChecks` / `runDriftCheck`. + * + * ## What `false` buys, stated precisely + * + * The datasource's rows are dropped before the boot gate looks at them, so for + * that datasource: no `onMismatch` policy is applied (a measured mismatch + * therefore CANNOT abort boot through it), no unreachable-remote warning is + * raised, and its objects are not counted in the all-clear. + * + * What it deliberately does NOT buy is the remote round-trip: `validateAll()` + * is the service's whole-farm entry and takes no datasource argument, so the + * introspection has already happened by the time this runs. Narrowing the work + * itself would mean composing the sweep out of the OPTIONAL scoped twin + * (`validateDatasource`), which changes what the sweep does when the twin is + * absent and changes the row set when it is present — and the ruling requires + * the `true`/default path to stay behaviourally identical. Recorded rather than + * quietly done. + * + * ## Why the test is `=== false` and not a truthiness check + * + * `checkOnBoot` is declared `z.boolean().default(true)`, so on a PARSED + * datasource the key is always materialized — `true` or an explicit `false`. + * Only that explicit `false` opts out. Everything else validates: an absent + * key, an unparsed or legacy stored row, a managed datasource with no + * `external` block at all, and a definition the metadata service could not + * hand back. Boot validation is the safe direction, so every uncertainty + * resolves towards running it. + * + * ⚠️ The value read here is the one the metadata service returns, i.e. the + * POST-parse definition — the same read point `resolveOnMismatch` has always + * used. It is deliberately not softened with a `??` alias chain: `checkonboot` + * and `validateonboot` are **not** accepted spellings that fold to this key, + * they are entries in `strictObject`'s `aliases` table, which runs only from + * the `unrecognized_keys` REJECTION path (measured: both spellings fail + * `DatasourceSchema.safeParse` with "Did you mean … → `checkOnBoot`?"). There + * is exactly one authorable spelling, so there is exactly one read. Pinned by + * `external-validation-checkonboot.test.ts`, which fails loudly if that ever + * stops being true — a real fold would need this read point revisited, not a + * consumer-side fallback (AGENTS.md Prime Directive #12). + */ +async function bootCheckEnabled( + loadDef: (datasource: string) => Promise, + datasource: string, +): Promise { + const ds = await loadDef(datasource); + return ds?.external?.validation?.checkOnBoot !== false; +} + +async function resolveOnMismatch( + loadDef: (datasource: string) => Promise, datasource: string, ): Promise<'fail' | 'warn' | 'ignore'> { - try { - const ds = (await metadata?.get?.('datasource', datasource)) as DatasourceDef | undefined; - return ds?.external?.validation?.onMismatch ?? 'fail'; - } catch { - return 'fail'; - } + const ds = await loadDef(datasource); + return ds?.external?.validation?.onMismatch ?? 'fail'; } function safeGet(ctx: PluginContext, name: string): T | undefined {