Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): honour external.validation.checkOnBoot in the boot validation sweep - #13149
Conversation
#13037) `checkOnBoot` was declared on `DatasourceSchema` with `.default(true)` and read by nothing: `ExternalValidationPlugin.start` hooked `kernel:ready` and called `runValidation(ctx)` unconditionally. An author who wrote `checkOnBoot: false` and left `onMismatch` at its default still got the boot sweep, and a measured mismatch still aborted boot with `ExternalSchemaMismatchError`. The sweep now resolves each datasource's parsed `checkOnBoot` and drops the rows of any datasource that set `false` before a verdict is drawn from them: no `onMismatch` policy, no unreachable-remote warning, not counted in the all-clear, and one info line naming what was skipped. `true` or absent leaves the sweep exactly as it was. Uncertainty resolves towards validating — an absent key, a legacy stored row, or a definition the metadata service could not read is checked, never inferred to have opted out. Scope pinned at the maintainer's ruling: the boot step only. `scheduleDriftChecks()` / `checkIntervalMs` stay independent, stated in an in-place comment at both read points and held by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2d6aa07ab1e4a336d8500ac68cd84280af39fd44 && git checkout 2d6aa07ab1e4a336d8500ac68cd84280af39fd44
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 56470d86bf25269b90fad0123cbee6282bc08be7 e0325019584bb840a9bb489c11f6146894d9109a && git checkout -B drift-repro 56470d86bf25269b90fad0123cbee6282bc08be7 && git merge --no-ff e0325019584bb840a9bb489c11f6146894d9109a
node scripts/docs-audit/affected-docs.mjs --json 56470d86bf25269b90fad0123cbee6282bc08be7 |
os-litant
commented
Aug 29, 2026
✅ PM 复核 —— ACCEPT。⭐ 我的派发令第三次踩同一个机制,dev 测出来并顶回,我认下
⛔ 认错:约束 5 的「别名折叠」半条建立在假前提上我在派发令里写「 这是假的。 实测
⇒ dev 没有按我写的去造一条会永远绿的空洞断言,而是把实测到的真相双向钉住( ⭐ 而这是今天同一个机制第三次咬人,⇒ 值得升格成规则
⇒ 三次、三个不同席位、两个不同 schema 面,全部把同一张拒绝提示表读成了折叠表。⛔ 这已经不是笔误,是这个机制的命名在系统性地误导读者。已把这条实测追加到 #13137 上,因为它把那张卡从" 四条硬约束逐条核对(实测)
⭐ 约束 2 的实现比我要求的更强,而反向验证证明了这一点:ablation(还原修复前的函数体)下 13 条 6 红 7 绿 —— 正是预测的分裂 —— 而混合那条的失败是 放宽探针: ⭐ 三条仪器纪律,每条都拦下了一次假读数
未测项逐条申报并各自引用门自己的判词,⛔ 没有一条被当成通过: 越界 finding,处置
处置ACCEPT。 保持 Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Closes#13037
Maintainer ruling 2026-08-29 (batch #2, verbatim 「同意」) — ADR-0049 disposition enforce, not remove. Option 2 (
retiredKey()tombstone + ADR-0087 D3 conversion) was refused, and this PR does not go near it.All measurements below were taken on the final commit,
e03250195.The defect
external.validation.checkOnBoothas been declared onDatasourceSchemawith.default(true)since the block was written, and nothing read it.ExternalValidationPlugin.starthookedkernel:readyand calledrunValidation(ctx)with no condition on it, so an author who wrotecheckOnBoot: falseand leftonMismatchat its default got the boot sweep anyway — and a measured mismatch threwExternalSchemaMismatchErrorand aborted boot, the exact outcome the key reads as opting out of.The
.default(true)is what makes this worse than an ignored key: it materializes the knob into every parse output, so a dead setting is byte-identical to an honoured one in stored and serialized datasources. Confirmed by parse, not by reading the declaration — a datasource authored without the key comes back as{"onMismatch":"fail","checkOnBoot":true}.What changed
runValidationnow resolves each datasource's parsedcheckOnBootand drops the rows of any datasource that setfalsebefore a verdict is drawn from them. For that datasource: noonMismatchpolicy is applied (so a measured mismatch cannot abort boot through it), no unreachable-remote warning is raised, and its objects are not counted in the all-clear. Oneinfoline names what was skipped and states that the verdict beside it covers the remaining datasources only.trueor absent leaves the sweep as it was. Every uncertainty resolves towards running the check — an absent key, an unparsed or legacy stored row, a managed datasource with noexternalblock, and a definition the metadata service could not read are all validated, never inferred to have opted out. A metadata outage must not silently produce an ungated boot; that is this card's failure class inverted.The gate is per datasource, because the sweep is whole-farm and the key is per-source.
Scope: the boot step only — stated in place, not just here
The ruling pinned the gate to the boot step, so the statement lives in the code at both read points, as the card asked: in
bootCheckEnabled's docblock, and as a⭐ [#13037]note onscheduleDriftChecks()itself ending "⛔ do not add acheckOnBootcondition below".checkIntervalMsbehaviour is untouched — a datasource that opts out of the boot check keeps whatever background drift checking it armed. A test holds that boundary mechanically, so an edit that extends the gate to the drift checker fails rather than merely contradicting a comment.One read, because there is one spelling — and the dispatch's premise on this was wrong
checkonboot/validateonbootas an alias fold, i.e. two further authorable spellings this gate would have to honour or else deliver half the published surface. Measured: it is not a fold. They are entries instrictObject'saliasestable, which runs only from theunrecognized_keysrejection path (strict-object.ts: "an alias runs only from theunrecognized_keyspath, so a declared key can never reach it"). Both spellings are refused at parse:with the canonical spelling parsing cleanly on the same document as a positive control. So
checkOnBootis the single authorable spelling and the gate has a single read point. ⛔ No??alias chain was added in the consumer (Prime Directive #12). This is pinned in both directions rather than merely noted: if a real fold is ever added, that test reds and sends its author to this gate's read point instead of letting a second spelling silently become inert.Tests —
packages/runtime/src/external-validation-checkonboot.test.ts(13)Both directions are pinned as separate assertions, per the ruling.
checkOnBoot: falsedatasource — driven to "a mismatch exists and boot still succeeds", not merely "validation was not called"truestill aborts — ADR-0112 envelope (code: 'EXTERNAL_SCHEMA_MISMATCH',status: 503), not a baretoThrow()true)warehouseopts out and mismatches,ledgerdoes not and mismatches ⇒ boot still aborts, anderr.datasource === 'ledger'DatasourceSchema.parse, and the parsed definition is what the gate honourstrueand is validatedcheckonboot/validateonbootare rejected, with the canonical spelling as positive controlcheckOnBoot: falsestill arms the background drift checker it asked forTest Files 4 passed (4) · Tests 44 passed (44)across this file plus the three pre-existingexternal-validation-*suites.Reverse verification
The pre-fix plugin body was restored from the branch point and the new file re-run. The mutation was proven on disk by hash (
98ccac8b…== the base blob,bootCheckEnabled4 → 0 occurrences,resolveOnMismatch(metadata0 → 1), and the restore leg proven the same way (hash back to the HEAD blob,git diff HEADempty).6 failed / 7 passed of 13 — the predicted split. The six are exactly the
false-direction pins; the seven that pass in both worlds are the unchanged-behaviour pins, which is what makes them unchanged-pins. The one worth quoting is #8:Without the fix the mixed boot aborts on the opted-out datasource. A bare
toThrow()assertion would have passed there.Gates — true exit codes, captured before any pipe
Green (
EXIT=0):check:nul-bytes,check:cross-package-test-inputs,check:objectql-double-limit,check:page-declaration-shape,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:type-check-coverage,check:changeset-gate-self-tests,check:objectui-changeset,check:pm-half-states,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-ci-filter-parity,check-comment-mask-adoption,check-cross-package-test-inputs,check-keyed-text-bounds,check-plugin-teardown-shape,check-undeclared-dep-imports,release-rehearsal-clone --self-test,docs-audit/check-affected-docs. Pluspnpm --filter @objectstack/runtime typecheckclean and the vitest run above.NOT MEASURED — recorded as such rather than as passes, each quoting its own verdict line:
check:type-check-debt --re-measure— refuses: "27 workspace dependenc(ies) … have no built type entry point on disk". Needs the full closure build; CI does that before this step.check:dual-build-cjs-loads—EXIT=3, "PREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured."check-test-completeness/check-shard-attestation— consume a turbo test log that only CI produces.pm/check-half-states—EXIT=3, its own text: "it is no reading at all" (noGITHUB_TOKENon that path in this container).One thing the ratchet caught, and it is worth knowing
packages/runtime/tsconfig.jsonexcludes**/*.test.ts, sopnpm --filter @objectstack/runtime typecheckreads no test file — verified with--listFiles: the plugin source appears once, the new test file zero times. A green typecheck therefore says nothing about a new test file, and this one nearly shipped on that. The layer is read by the shrink-onlyTEST_DEBTratchet (frozen at 217 for this package), so it was measured directly instead: the test file as first written added one TS2835 (extension-less relative import undernodenext), moving the layer 217 → 218 and turning that ratchet red in CI. Fixed by importing./external-validation-plugin.js; re-measured at 217 total, 0 attributable to either of my files.The emitted public type surface does not move:
bootCheckEnabled,createDatasourceDefLoaderand the extendedDatasourceDefare all module-private, and their only appearances indist/index.d.tsare inside carried-over docblock text. (An earlier count of "3 hits forDatasourceDef" was my own substring grep matching the unrelated exportedDefaultDatasourceDefinition; corrected here rather than left standing.)Not a contract-face change
Clause-② no, as ruled. No schema, no key and no accepted spelling moves;
packages/specis untouched.examples/app-showcase/.../showcase-external.datasource.tsauthorscheckOnBoot: truetoday, so it lands on the unchanged branch and the example app's behaviour is identical — as the dispatch warned, observing it change would mean the gate was wired backwards.Known limit, recorded rather than quietly done
checkOnBoot: falsesuppresses the boot verdict for that datasource, not the remote round-trip:validateAll()is the service's whole-farm entry and takes no datasource argument, so introspection has already happened by the time the gate runs. Narrowing the work itself would mean composing the sweep out of the optional scoped twinvalidateDatasource, which changes what the sweep does when the twin is absent and changes the row set when it is present — both ruled out by the requirement that thetruepath stay behaviourally identical, and by "no contract face change". Recorded here and handed to the PM as afindingrather than expanded into this PR.api.github.comanswers "GitHub access is not enabled for this session" to this container's token, andghis not installed), and filing without that sweep is how parallel seats mint duplicates.Generated by Claude Code
Generated by Claude Code