Skip to content

fix(metadata-protocol): one shared non-raising table-presence probe, so a normal boot stops printing DATABASE_ERROR for an expected miss (#17175) - #17617

Merged
os-musk merged 5 commits into
mainfrom
claude/issue-17175-sequences-probe-error-level
Sep 11, 2026
Merged

fix(metadata-protocol): one shared non-raising table-presence probe, so a normal boot stops printing DATABASE_ERROR for an expected miss (#17175)#17617
os-musk merged 5 commits into
mainfrom
claude/issue-17175-sequences-probe-error-level

Conversation

@os-musk

@os-musk os-musk commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

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 unexplained DATABASE_ERROR every boot), ⛔ not B (a new key on DriverOptionsSchema is a contract-review-tier change, and a different card).

What was wrong

Two migrations on the kernel:ready hook asked "does this table exist?" with a statement that cannot succeed when the answer is no, and read the refusal as the answer:

migration statement fires on
seed-tenancy-backfill.ts:533 SELECT "tenant_id" FROM "_objectstack_sequences" WHERE 1 = 0 every boot of an install that never allocated an autonumber
sys-setting-identity-index.ts:430 SELECT 1 FROM sys_setting WHERE 1 = 0 every boot of a kernel without the optional service-settings

Both 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, on warn, i.e. stderr:

[sql-driver] DATABASE_ERROR — the backend refused a raw statement (SQLITE_ERROR). ...
statement: SELECT "tenant_id" FROM "_objectstack_sequences" WHERE 1 = 0;
dialect: SELECT "tenant_id" FROM "_objectstack_sequences" WHERE 1 = 0 - no such table: _objectstack_sequences

⚠️ A correction to the card, measured rather than argued. The title and body say the line is logged at ERROR. It is emitted at 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, because console.warn still writes to stderr carrying both the DATABASE_ERROR token and no 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 needs readObject, the name of the thing the caller was reading, both to avoid the #13324 fail-open and because driver-error-classification.callers.test.ts fails any in-repo call that omits it. The raw path has no such name: execute() takes a string, and rawStatementFaultError declares no targeted table (pinned by sql-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.ts asks the catalog instead, one arm per dialect family, mirroring the same client spelling sets SqlDriver itself emits for:

  • SQLite — SELECT 1 FROM sqlite_master WHERE type IN ('table', 'view') AND name = '...'
  • Postgres — SELECT 1 WHERE to_regclass('"..."') IS NOT NULL
  • MySQL — SELECT 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 catch the 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:

An unrecognised dialect gets no guessed catalog statement. It keeps the caller's own WHERE 1 = 0 probe — which still raises, and still prints, exactly as before — but its refusal is now classified with isMissingTableError(error, table), so a refusal naming a different relation, a permission denial or a dropped connection reports unreadable where it used to be swallowed as absence. Both arms, both directions, are pinned in read-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:

site why it is not folded in here
runtime-index-preflight.ts:162 buildPresenceSql operator-invoked (os migrate duplicates), not a boot path — and it already reports unreadable as its own status
seed-tenancy-backfill.ts:775 buildSequencesKeyShapeProbeSql a COLUMN-existence probe, and it runs only after the table is known present AND a split was found, so it is off the happy path
packages/cli/src/commands/migrate/duplicates.ts:393 same idiom, different package, operator-invoked, outside this card's declared surface

Verification

⛔ The card's hotclm repro is not runnable here. It was not run and is not reported as run. In-repo evidence instead.

End to end, real SqlDriver over real better-sqlite3 (packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts, 14 passed): on a database with no _objectstack_sequences, backfillSeedTenancy returns absent and 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 full DATABASE_ERROR line at warn. 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-dist proofs the ablation discipline requires (ablation-dist-preflight reported the marker live in 2 built files; afterwards --absent reported it gone from all 24 and the whole tree clean against HEAD). The ablation reproduced the card's line verbatim inside the assertion:

AssertionError: expected [ Array(1) ] to deeply equal []
+ "warn: [sql-driver] DATABASE_ERROR — the backend refused a raw statement (SQLITE_ERROR). ...
   statement: SELECT "tenant_id" FROM "_objectstack_sequences" WHERE 1 = 0; dialect: ... - no such table: _objectstack_sequences"

Dialect coverage, stated rather than implied.

  • SQLite — pinned end to end, above.
  • MySQL — pinned on the live server in 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.
  • Postgres — NOT MEASURED against a live server. Its statement text is pinned; running it is not. This partly falsifies the re-dispatch's Zone 3 premise, and the measurement is: this package's CI leg supplies OS_TEST_MYSQL_URL only and filters vitest to the substring live-mysql; metadata-protocol has no pg dependency and no live-PG harness; live PG exists only in packages/drivers/driver-sql, which this card made read-only. Closing it needs three things this PR does not take on its own authority: a pg devDependency, a live-postgres sibling file, and widening that CI step's filter plus an OS_TEST_POSTGRES_URL line.

Gates. dispatch-gates.mjs --commands derived 59 families against the actual changed files; all 59 were run with exit codes written to the per-issue scratchpad before being read, and --ran reconciles 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 merging origin/main; the family set was identical. pnpm lint (repo-wide, --no-inline-config) exits 0, so no narrowing is claimed. @objectstack/metadata-protocol 2526 passed / 12 skipped; both packages typecheck clean. All figures are from 850f76a0c.

Two gate findings were repaired rather than worked around: check:doc-authoring flagged the tracker ids I had put in the two new operator-facing strings (they now live in adjacent // comments), and check:type-check-debt exited 3 once because my runner's NODE_OPTIONS was 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.json is untouched; check:engine-double-contract is green.

Boot context

⚠️ A normal boot prints at least two of these. The sibling from the same boot is #17176 (sys_oauth_resource UNIQUE 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's tableIsPresent prints the identical line on every boot of a kernel that does not register the opt-in service-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 real hotclm boot; recorded as a candidate, and folding it in closes the class either way.

packages/runtime/src/app-plugin.ts:1621 documents the backfill's first act as "returns no-split having written nothing and logged nothing". That was measurably false; with this change it is true again, so the sentence needs no edit.

Why minor

  • SeedTenancyBackfillStatus gains 'unreadable'. An OUTPUT union, so no input a caller writes is affected; the one consumer shape that could break is an exhaustive switch with a never default, which is why this is not a patch.
  • ensureSysSettingIdentityIndex gains 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.
  • buildSequencesPresenceSql and buildSysSettingPresenceSql are unchanged in text and still exported; they are no longer what the boot path runs first.
  • isResultSet and normalizeRows moved to read-probe.ts and are re-exported from seed-tenancy-backfill.ts unchanged, so the package index and every importer see no difference.

Acceptance notes

  • File surface is the re-declared one — 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.ts gains 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 in packages/runtime's existing integration test because that is the only place in the repo where a real SqlDriver and this migration meet.
  • packages/drivers/driver-sql/src/ is untouched, as ruled.
  • noted, not filed: the SQL driver plugin's onEnable destructures logger from the plugin context and never installs it on the driver, so SqlDriver.logger stays 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.
  • noted, not filed: driver-exec.ts gained resolveDriverClientName, moved from seed-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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 30 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/seed-tenancy-repair.mdx (via tenant_id (literal, a string literal on a changed line))
  • content/docs/kernel/services-checklist.mdx (via assembleMetadataProtocol (symbol, a top-level function))
  • content/docs/permissions/record-view-auditing.mdx (via tenant_id (literal, a string literal on a changed line))
  • content/docs/protocol/kernel/config-resolution.mdx (via tenant_id (literal, a string literal on a changed line))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via tenant_id (literal, a string literal on a changed line))
  • content/docs/releases/v17/17-0.mdx (via tenant_id (literal, a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4packageMentionDocs.

Which tree this was computed on

This run read content/docs from 02f8d9e556cb0b357532223c238b4a90b98c64fe — the merge of head 850f76a0cdd6eaecb39d575cf3142aeab4c9b020 into base 0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A SELECT ... WHERE 1 = 0 column-existence probe against _objectstack_sequences is logged at ERROR on a normal boot, before the table exists

2 participants