Skip to content

datasource.external.validation.checkOnBoot is declared-but-unenforced — the boot sweep runs unconditionally, so checkOnBoot: false still aborts boot under the default onMismatch: 'fail' #13037

Description

@os-trump

Found while re-closing packages/spec/liveness/datasource.json for #13003 batch 2 (adoption of the symbol-anchor citation grammar). Filed rather than acted on: this is an ADR-0049 enforce-or-remove disposition with its own card shape, and #13003's dispatch forbids status changes in its PRs. Unassigned, unlabelled — for PM triage.

The measurement (tree c459da6b)

DatasourceSchema's external.validation block declares three members. Two are read; the third is not.

Repo-wide probe for the identifier checkOnBoot, excluding tests:

  • packages/spec/src/data/datasource.zod.ts — the declaration itself, checkOnBoot: z.boolean().default(true), plus two alias spellings in the key-fold table (checkonboot, validateonboot) and the block default .default({ onMismatch: 'fail', checkOnBoot: true }).
  • examples/app-showcase/src/system/datasources/showcase-external.datasource.ts — an example app AUTHORING it (once live, once commented out).

That is the whole population. No reader anywhere in packages/**.

Why that is a behaviour bug and not tidying

ExternalValidationPlugin.start subscribes to kernel:ready and calls runValidation(ctx) with no condition on it:

start=(ctx: PluginContext): void=>{ctx.hook('kernel:ready',async()=>{awaitthis.runValidation(ctx);// Boot validation done; arm any background drift checks (ADR-0015 §5.2).awaitthis.scheduleDriftChecks(ctx);});};

The sibling members ARE read, which is what makes the gap legible rather than a whole-block miss:

  • external.validation.onMismatchresolveOnMismatch(), ds?.external?.validation?.onMismatch ?? 'fail'.
  • external.validation.checkIntervalMsscheduleDriftChecks(), def?.external?.validation?.checkIntervalMs.

So an author who writes validation: { checkOnBoot: false } and leaves onMismatch at its default gets the boot sweep anyway, and a measured mismatch throws ExternalSchemaMismatchError and aborts boot — the exact outcome the key reads as opting out of. The .default(true) also materializes the inert knob into every parse output, so it is present in stored/serialized datasources and indistinguishable from an honoured setting.

Why no gate could ask

external.validation is a container entry in the ledger and the gate classifies at one-level granularity, so checkOnBoot sits BELOW the walk — it has no row of its own, and the container's row is legitimately live on its two real readers. The ledger's note claimed the third member was live in prose only ("checkOnBoot gates the boot-time one"), and prose is not checked by anything. That sentence is withdrawn in the #13003 batch-2 PR; the container entry keeps its live verdict on the two readers that exist.

Disposition, not decided here

Two shapes, both ADR-0049-legible:

  1. Enforce — gate runValidation on the resolved checkOnBoot (per datasource, since the sweep is repo-wide and the key is per datasource). Cheap, and the ADR-0015 §5.2 text already describes the boot gate as a policy the author sets.
  2. RemoveretiredKey() tombstone plus an ADR-0087 D3 conversion, on the flow.active / tool.active precedent (both retired in v17 for claiming a gate they did not deliver).

Option 1 looks right on the face of it because the capability is coherent and one example app already authors it, but the choice is a maintainer call on real demand, and the removal route has the stronger in-repo precedent for exactly this shape. Not judged here.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions