Skip to content

fix(driver-sql): refuse an unresolvable WHERE column on both find() and count() (#8790) - #8927

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-8790-unresolvable-where-column-refusal
Aug 15, 2026
Merged

fix(driver-sql): refuse an unresolvable WHERE column on both find() and count() (#8790)#8927
hotlong merged 4 commits into
mainfrom
claude/issue-8790-unresolvable-where-column-refusal

Conversation

@hotlong

@hotlonghotlong commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Fixes#8790

One unresolvable WHERE column had two answers. find() returned [] in silence; count() threw the dialect's own error with the statement's bound literals inlined. Both now refuse with the ADR-0112 envelope this path already declares — INVALID_FILTER / 400, naming the column — per the maintainer ruling of 2026-08-15 (issue comment 5302931807).

⚠️ Read this first: what a DOTTED key does, stated plainly

This is the question the earlier revision of this body left implied, and the answer is per dialect, because the three backends do not agree on what a dotted key even is. All figures below were measured against live servers (PostgreSQL 16.13, MySQL 8.0.46), running origin/main and this branch side by side.

dialectclassification of {'title.x': v}BEFORE findBEFORE countAFTER findAFTER countchanged here
sqliteundefined column (no such column: title.x)[] 0 rowsraw SQLITE_ERRORINVALID_FILTER/400INVALID_FILTER/400yes
postgresundefined table (42P01 missing FROM-clause entry)raw 42P01raw 42P01raw 42P01raw 42P01no — identical
mysqlundefined column (1054 Unknown column 'title.x')raw 1054raw 1054raw 1054raw 1054no — identical

So: is a dotted key refused INVALID_FILTER? On SQLite yes; on Postgres and MySQL no — the raw dialect error escapes, exactly as it did before this PR, byte for byte.

Is the Postgres raw escape a new leak introduced here? No. knex compiles {'title.x': v} to the qualified reference "title"."x", so Postgres reads title as a table and raises 42P01 (undefined_table), not 42703 (undefined_column). That text contains neither no such column nor column + does not exist, so it took the else { throw error } branch on origin/main and takes the same branch now — the classifier here is byte-identical to the inline predicate it replaced. The return [] terminal was never reachable for a dotted key on Postgres. It is a real, pre-existing raw-error escape on an axis this card does not own; it is reported to the PM and deliberately unresolved here.

Why the SQLite arm is not minting #8371's verdict. The driver never asks "is this a dotted path?" — it asks the database whether the column resolved, and refuses what the database rejected. SQLite hands it one message for both {'title.x': v} (a path) and a column literally namedtitle.x; they are not separable at this layer. Excluding dotted keys would require the driver to inspect the key for a ., which is judging dotted-ness — #8371's call — and would re-split the two halves on SQLite, reintroducing this card's own defect on that route. The refusal follows the dialect's classification, not a policy about paths.

Correction to the card's framing, worth recording: the headline repro (find()[], count() → throws) is SQLite-specific. On Postgres the dotted key never produced the silent []; both halves already agreed. On Postgres the genuine two-answer defect is the plain unknown column ([] vs raw 42703) — which is what this PR closes.

The test suite reflects exactly this: the refusal pins cover the ruled scope (a plain column the table lacks), and the dotted key is recorded per dialect in a DOTTED_STATUS_QUO table naming #8371 as the owner of the verdict, alongside a both-halves-agree assertion that holds whichever way #8371 lands.

The ruling was premised, so the premise was verified first

The ruling's part 2 required a survey before changing behaviour: no in-repo caller may depend on the permissive [] to render. The premise holds — no fork. Method and evidence:

① The platform's declared posture on this exact condition is already refusal.assertFilterFieldsExist (@objectstack/metadata-protocol) answers INVALID_FIELD / 400 for everything reaching findData — the REST list route, POST /data/:object/query, the export route, the RPC dispatcher — with the sentence "A filter on a field that does not exist can only match zero records, so the query was refused instead of answered with an empty list." The driver's [] was only ever reachable where that door returns early (resolveQueryFields yields nothing), or where a dotted key clears it on its head segment. So the narrowing aligns the backstop with the door, and this refusal echoes that sentence verbatim rather than inventing a second vocabulary.

② The two named UI consumer classes do not execute filters in this repo. Saved views (ui/view.zod.ts) and dashboard widgets (ui/dashboard.zod.ts) are metadata consumed by the UI, which composes a REST query — so they reach the driver through the door in ① and are refused before it. Grepped for a server-side executor of view.filters / widget filters: none outside packages/spec.

③ The unpopulated-registry backstop path. The driver comment that names it (the cloud multi-tenant runtime) ties the backstop to the projection"where the projection otherwise zeroes the list" — and the #7589 ruling comment likewise keeps "the unknown-PLAIN-column tolerance ... an unknown plain column is simply absent from each row." Neither rationale mentions the WHERE. The ladder's projection and ORDER-BY recoveries are untouched, so the backstop keeps doing the job it was justified by.

④ Direct engine callers with authored filters already tolerate a throw.plugin-reports' executeReport and service-messaging's recipient resolver forward an authored filter into engine.find without the door in ①. The resolver already catches and logs ('... lookup failed; 0 recipients'), degrading to the same outcome with a diagnosable warning instead of a silent one. Deferred DDL — the one shape where a declared field can lack a column — is armed only by the CLI migrate planner (setDeferredDdl), a dry-run posture that serves no queries.

⑤ Mechanically: ~15,000 consumer tests, all green (below).

What changed, and what deliberately did not

The #3821 ladder's rungs are all built from buildBase(), which always re-applies query.where — so it can drop a projection and drop an ORDER BY, but never the clause that failed. Both rungs raised the same error and the method fell to return []. Only that terminal became a refusal. Both recoveries are kept, and the suite pins them: "rows matter more than their order" is about how rows are presented and does not transfer to a predicate — a dropped sort is a correct answer in an unhelpful order, a dropped WHERE is records the caller excluded.

count() needed no ladder — it carries no projection and no ORDER BY, so the only clause an unresolvable column can be in is the one no rung may drop. It needed the ladder's terminal.

The caller-visible message names the column and the object and nothing else. The dialect's own message — compiled statement, bound literals and all — goes to the server log (#7929's line, applied to the one refusal on this path raised from a dialect error rather than composed from the filter AST).

MySQL is untouched throughout: its ER_BAD_FIELD_ERROR wording matches neither arm of the classifier, so it gets no envelope and no #3821 recovery. Filed as #8926 rather than widened in passing — widening would also hand MySQL the recoveries it has never had, an accept-set change in the opposite direction. The gap is pinned as a fact in the new suite, so it goes red the day someone closes it.

Verification

Ablation-proved — the fix was committed first, then each half reverted and the pins observed going red, direction predicted beforehand:

  • Leg 1, terminal restored to return []: 6 failed / 17 passed, all on the find() half and the shared pins. Ladder-preservation and control pins stayed green — which is what shows the suite discriminates.
  • Leg 2, count() unguarded: 5 failed / 18 passed, mirror image — expected 'SQLITE_ERROR' to be 'INVALID_FILTER', and the redaction pin firing on the real disclosure: expected 'select count(*) as `count` from `unre…' not to contain 'zz-bound-literal-must-not-leak'.

driver-sql in the exact Temporal Conformance (live PG + MySQL) job configuration — live Postgres at Asia/Shanghai, live MySQL at +08:00, TZ=America/New_York, OS_EXPECT_LIVE_DIALECT_MATRIX=1: 103 files / 2185 tests, all passing. The three earlier dotted-key failures are gone, and the Postgres cell now genuinely runs instead of being skipped as it was in the first local pass — which is why they were missed.

packageresult
driver-sql (live PG + MySQL)2185 passed
spec10687 passed
driver-turso (subclass)994 passed
driver-sqlite-wasm (subclass)394 passed
objectql3675 passed
metadata-protocol1487 passed
rest1948 passed
service-analytics1722 passed
service-messaging229 passed
plugin-reports70 passed

Consumers selected with the prefix filter (pnpm --filter '...@objectstack/driver-sql' — downstream dependents); both SqlDriver subclasses included because they inherit findRows/count unmodified.

Gates

Union run at 1fe3d65ca — the merge commit, working tree clean, on a fully rebuilt workspace. Re-run in full after the merge rather than carried over: a merge can import another PR's test surface, so the ratchets in particular are only meaningful when measured at this sha.

All green: check:query-options-erasure (240, at the ceiling), check:type-check-debt, check:type-check-coverage, check-dev-prereqs, check:nul-bytes, check:changeset-gate-self-tests, check:merge-driver, check:objectui-changeset, check:spec-parsed-alias, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, plus the spec ledger family check:migration-registry, check:spec-changes, check:upgrade-guide, check:authorable-surface.

check:query-options-erasure caught a real regression in this PR: the new suite grew the test surface 240 → 256 via sixteen as any casts on the query argument. Raising a ceiling is maintainer-only, so the queries were typed as DriverQuery instead — precisely the erasure that type exists to prevent.

Merge with main (1fe3d65ca): the only conflict was packages/spec/src/migrations/registry.ts, which is generated from entries/. Resolved by regeneration rather than textually, via scripts/pm/os-regen-merge.sh (merge → commit → regenerate, in that order — regenerating in MERGE state silently rolls artifacts back to the fork point). Both intents verified present afterward by exact-name grep: main's datasource-config-url-query-credential-refused and this PR's driver-sql-unresolvable-where-column-refused, index and entry body.

Changeset

BREAKING, per the ruling — an accept-set narrowing on a GA public data API. Shipped as minor under the lockstep launch-window convention (check-changeset-no-major.mjs), with the migration prescription registered in the ADR-0087 ledger as the semantic entry driver-sql-unresolvable-where-column-refused under protocol major 18.

Cross-reference

#8371 (FILTER-axis dotted-path verdict) sits on the same axis and is cross-cited by the ruling's instruction; it is not addressed by this PR and remains open. No ordering dependency in either direction: this refusal is the backstop and stays correct under any outcome there. The measurements in the first section are offered as input to it — three dialects, three different classifications of the same dotted key.


Generated by Claude Code

…nd count() (#8790)
One predicate had two answers: the #3821 ladder's rungs all re-apply
`query.where`, so a WHERE-side unknown column could never be recovered and
fell to `return []`, while `count()` — which has no ladder — threw the
dialect's own error with the bound literals inlined.
Both halves now refuse with the ADR-0112 envelope this path already declares:
`INVALID_FILTER` / 400, naming the column. The dialect message goes to the
server log instead of the caller. The ladder's projection and ORDER-BY
recoveries are deliberately untouched — only the WHERE-failure terminal
becomes a refusal (maintainer ruling 2026-08-15).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
`check:query-options-erasure` measured the new suite growing the test
surface 240 -> 256: sixteen `as any` casts on the query argument. That is
the erasure `DriverQuery` exists to prevent — its own docblock records that
a direct caller holding only a `where` reached for a blanket cast and lost
`where`'s type with it. Typed instead of ceiling-raised; the ratchet is back
at 240.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
@vercel

vercelBot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 15, 2026 8:38pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-sql, @objectstack/spec.

108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/spec)
  • content/docs/automation/approvals.mdx(via @objectstack/spec)
  • content/docs/automation/connectors.mdx(via @objectstack/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via packages/spec)
  • content/docs/concepts/north-star.mdx(via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/cli.mdx(via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx(via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/glossary.mdx(via @objectstack/driver-sql)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/spec)
  • content/docs/permissions/authorization.mdx(via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/permissions/system-context.mdx(via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/anatomy.mdx(via @objectstack/driver-sql)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/apps.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v17.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/spec)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 15, 2026
… dotted key per dialect
Live-measured on PG 16.13 and MySQL 8.0.46 (origin/main vs this branch): the
three dialects do not agree on what a dotted key IS. knex compiles
{'title.x': v} to the qualified reference "title"."x", so Postgres reads
`title` as a TABLE and raises undefined_table (42P01), not undefined_column
(42703) — a shape the classifier does not match and never has. On Postgres a
dotted key therefore raised a raw 42P01 on BOTH halves before this card and
after it, byte for byte; the card's headline repro is SQLite-specific.
The refusal pins now cover the RULED scope (a plain column the table lacks),
which holds on both recognised dialects. The dotted key is RECORDED per
dialect in DOTTED_STATUS_QUO with #8371 — the open FILTER-axis dotted-path
verdict — named as the owner, plus a both-halves-agree assertion that is true
whichever way #8371 lands. No classifier change: teaching it 42P01 would mint
a dotted-path verdict at the driver and pre-empt that card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…esolvable-where-column-refusal
# Conflicts:
#	packages/spec/src/migrations/registry.ts
@hotlong
hotlong marked this pull request as ready for review August 15, 2026 23:15
@hotlong
hotlong added this pull request to the merge queueAug 15, 2026
Merged via the queue into main with commit 716ac9bAug 15, 2026
30 checks passed
@hotlong
hotlong deleted the claude/issue-8790-unresolvable-where-column-refusal branch August 15, 2026 23:33
os-project-manager pushed a commit that referenced this pull request Aug 16, 2026
…8939 relay)
gen:migration-registry, spec build, gen:export-origins, gen:api-surface,
gen:spec-changes, gen:upgrade-guide, gen:openapi restore. check:generated:
all 13 artifacts up to date. Survival asserted on the merged tree, one hit
each in the regenerated registry: engine-dotted-filter-refused (this
branch), driver-sql-unresolvable-where-column-refused (#8927),
filter-preset-ordering-comparand-refused (#8935),
identity-api-key-schema-retired (#8932) - plus implementation bodies:
sql-driver.ts INVALID_FIELD refusal + envelope test (#8927),
isDateRangePresetName (#8935), ApiKeySchema still absent (#8932),
classifyDottedFilterHead at both doors (this branch).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude