fix(metadata-protocol): one shared non-raising table-presence probe, so a normal boot stops printing DATABASE_ERROR for an expected miss (#17175) - #17617
Conversation
Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
…for the kernel:ready migrations Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
…robe reports Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
…quences-probe-error-level
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 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 02f8d9e556cb0b357532223c238b4a90b98c64fe && git checkout 02f8d9e556cb0b357532223c238b4a90b98c64fe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4 850f76a0cdd6eaecb39d575cf3142aeab4c9b020 && git checkout -B drift-repro 0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4 && git merge --no-ff 850f76a0cdd6eaecb39d575cf3142aeab4c9b020
node scripts/docs-audit/affected-docs.mjs --json 0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4
|
Fixes #17175
Clause-②: no
A caller-side probe that stops raising moves no accept set, adds no key to a published payload and mints no error code. The one published-type movement is an OUTPUT union gaining a member; it is named under "Why
minor" below.Seat ruling C (
5628514572): one shared non-raising presence helper, owned by the layer these probes live in. ⛔ Not A (one site of at least two leaves the operator reading an unexplainedDATABASE_ERRORevery boot), ⛔ not B (a new key onDriverOptionsSchemais a contract-review-tier change, and a different card).What was wrong
Two migrations on the
kernel:readyhook asked "does this table exist?" with a statement that cannot succeed when the answer is no, and read the refusal as the answer:seed-tenancy-backfill.ts:533SELECT "tenant_id" FROM "_objectstack_sequences" WHERE 1 = 0sys-setting-identity-index.ts:430SELECT 1 FROM sys_setting WHERE 1 = 0service-settingsBoth are correct on their own terms. Both make
SqlDriver.execute()'s raw terminal write the statement and the dialect's message to the operator's log on the way out.Measured on this tree against real
better-sqlite3, with every console channel captured — exactly one line per probe, onwarn, i.e. stderr:warn. That is what closed the ruling's second shape — reporting the handled miss "at a level that is not ERROR" is already the state of the code and did not fix the harm, becauseconsole.warnstill writes to stderr carrying both theDATABASE_ERRORtoken andno such table.⛔ Why the repair is not in the driver
Quietening a refusal requires classifying it. This repo has exactly one predicate for that —
isMissingTableError— and it needsreadObject, the name of the thing the caller was reading, both to avoid the #13324 fail-open and becausedriver-error-classification.callers.test.tsfails any in-repo call that omits it. The raw path has no such name:execute()takes a string, andrawStatementFaultErrordeclares no targeted table (pinned bysql-driver-16019-raw-statement-fault-envelope.test.ts). An unclassified demotion of the whole raw terminal would quieten real failures too.⇒ The caller knows the table. The driver does not. So the probe moves, not the log — and it moves once.
What changed
New
packages/metadata-protocol/src/migrations/read-probe.tsasks the catalog instead, one arm per dialect family, mirroring the same client spelling setsSqlDriveritself emits for:SELECT 1 FROM sqlite_master WHERE type IN ('table', 'view') AND name = '...'SELECT 1 WHERE to_regclass('"..."') IS NOT NULLSELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = '...'None of them reads FROM the table it is asking about, so a missing table cannot make the probe fail. Both migrations call it.
⛔ The binding fence, and how it is held
The way this repair can go wrong is its own: a catalog arm mis-compiled for some dialect raises, is caught by the same
catchthe expected miss uses, and reads as "the table is not there" — turning a stored-row data repair into a silent no-op on whichever dialect nobody exercised. That is strictly worse than the noisy log.So the probe answers four verdicts, not a boolean:
present/absent— the catalog ANSWERED.absentis silent and normal.no-answer— the seam accepted the statement and returned no result set. backfillSeedTenancy reportsno-splitover a driver it never queried — its ownabsentbranch is unreachable on the memory driver #10789's ruling stands: stillabsentwith thedetailthat separates it.unreadable— ⛔ the probe itself failed for a reason that is not "no such table". Neverabsent, returned to the caller, and reported atwarn.An unrecognised dialect gets no guessed catalog statement. It keeps the caller's own
WHERE 1 = 0probe — which still raises, and still prints, exactly as before — but its refusal is now classified withisMissingTableError(error, table), so a refusal naming a different relation, a permission denial or a dropped connection reportsunreadablewhere it used to be swallowed as absence. Both arms, both directions, are pinned inread-probe.test.ts.The class census the ruling asked for
Two members were ruled in scope and both are closed in this PR. The census found three more of the same idiom; per the ruling I am reporting them rather than widening to them, and none of them prints on a normal boot:
runtime-index-preflight.ts:162buildPresenceSqlos migrate duplicates), not a boot path — and it already reportsunreadableas its own statusseed-tenancy-backfill.ts:775buildSequencesKeyShapeProbeSqlpackages/cli/src/commands/migrate/duplicates.ts:393Verification
⛔ The card's
hotclmrepro is not runnable here. It was not run and is not reported as run. In-repo evidence instead.End to end, real
SqlDriverover real better-sqlite3 (packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts, 14 passed): on a database with no_objectstack_sequences,backfillSeedTenancyreturnsabsentand the driver's log channels receive nothing; and ⭐ in the same test, against the same driver and the same sink, a statement the backend genuinely refuses still writes its fullDATABASE_ERRORline atwarn. A test proving the line is gone without proving real errors survive would be a test for the wrong thing.Reverse verification, direction predicted BEFORE running — restore the pre-#17175 raising probe in step 1, rebuild, and the two #17175 cases go red while the other twelve stay green. Run with the on-disk and in-
distproofs the ablation discipline requires (ablation-dist-preflightreported the marker live in 2 built files; afterwards--absentreported it gone from all 24 and the whole tree clean against HEAD). The ablation reproduced the card's line verbatim inside the assertion:Dialect coverage, stated rather than implied.
seed-tenancy-backfill.live-mysql.test.ts: the catalog statement is added to the "every statement the migration builds PARSES and runs" list, plus both directions (a table that is there, a table that is not) and a measurement that the arm is scoped to the CONNECTED schema.OS_TEST_MYSQL_URLonly and filters vitest to the substringlive-mysql;metadata-protocolhas nopgdependency and no live-PG harness; live PG exists only inpackages/drivers/driver-sql, which this card made read-only. Closing it needs three things this PR does not take on its own authority: apgdevDependency, alive-postgressibling file, and widening that CI step's filter plus anOS_TEST_POSTGRES_URLline.Gates.
dispatch-gates.mjs --commandsderived 59 families against the actual changed files; all 59 were run with exit codes written to the per-issue scratchpad before being read, and--ranreconciles 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — every family recorded a code and none is 3). Re-derived and re-run in full after mergingorigin/main; the family set was identical.pnpm lint(repo-wide,--no-inline-config) exits 0, so no narrowing is claimed.@objectstack/metadata-protocol2526 passed / 12 skipped; both packages typecheck clean. All figures are from850f76a0c.Two gate findings were repaired rather than worked around:
check:doc-authoringflagged the tracker ids I had put in the two new operator-facing strings (they now live in adjacent//comments), andcheck:type-check-debtexited 3 once because my runner'sNODE_OPTIONSwas tighter than the ceiling the gate pins — re-run at the gate's own ceiling it is green.⛔ No engine double is registered by any new test, so
scripts/engine-double-contract.pinned.jsonis untouched;check:engine-double-contractis green.Boot context
sys_oauth_resourceUNIQUE refusal), a different package and a different lane — ⛔ not folded here.⭐ And the third finding triage asked about is very likely the second member closed by this PR:
sys-setting-identity-index.ts'stableIsPresentprints the identical line on every boot of a kernel that does not register the opt-inservice-settings. Its own docblock states this card's cost verbatim — "reporting one unactionable finding per boot is how the actionable ones stop being read" — while paying it. ⛔ Not confirmed against a realhotclmboot; recorded as a candidate, and folding it in closes the class either way.packages/runtime/src/app-plugin.ts:1621documents the backfill's first act as "returnsno-splithaving written nothing and logged nothing". That was measurably false; with this change it is true again, so the sentence needs no edit.Why
minorSeedTenancyBackfillStatusgains'unreadable'. An OUTPUT union, so no input a caller writes is affected; the one consumer shape that could break is an exhaustiveswitchwith aneverdefault, which is why this is not apatch.ensureSysSettingIdentityIndexgains an optional third parameter. Two-argument callers keep today's behaviour exactly — without a client there is no catalog arm and the pre-existing probe runs.buildSequencesPresenceSqlandbuildSysSettingPresenceSqlare unchanged in text and still exported; they are no longer what the boot path runs first.isResultSetandnormalizeRowsmoved toread-probe.tsand are re-exported fromseed-tenancy-backfill.tsunchanged, so the package index and every importer see no difference.Acceptance notes
packages/metadata-protocol/src/migrations/plus tests — with two declared additions inside the same package and one test file elsewhere:packages/metadata-protocol/src/plugin.tsgains two lines so the boot hook hands the migration the dialect it needs (without it the probe silently falls back to the raising statement this card is about), and the end-to-end pin lives inpackages/runtime's existing integration test because that is the only place in the repo where a realSqlDriverand this migration meet.packages/drivers/driver-sql/src/is untouched, as ruled.onEnabledestructuresloggerfrom the plugin context and never installs it on the driver, soSqlDriver.loggerstays the console default in every production boot — which is why a level-based repair inside the driver would have been inert for hosts. Successor: whoever revisits the driver's log wiring.driver-exec.tsgainedresolveDriverClientName, moved fromseed-tenancy-backfill.ts's private copy when a second site needed the same walk. Same three lookups, same order, same guards.🤖 Generated with Claude Code
https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM
Generated by Claude Code