Skip to content

driver-sql: MySQL's unresolvable-column wording is matched by neither arm of the recovery/refusal predicate, so MySQL gets no ADR-0112 envelope and no #3821 recovery #8926

Description

@hotlong

Found while implementing #8790 (unresolvable WHERE column refuses on both halves). Filed separately because closing it is an accept-set change in the opposite direction from that card's ruling, so it should not ride along on it.

The gap

SqlDriver decides "is this an unresolvable column?" with one predicate (isUnresolvableColumnError, extracted from the inline test the #3821 ladder carried):

message.includes('no such column')                                  // SQLite
|| (message.includes('column') && message.includes('does not exist'))  // Postgres

MySQL spells the same condition Unknown column 'x' in 'where clause' / 'field list' (ER_BAD_FIELD_ERROR). It contains column but not does not exist, so neither arm matches. Two consequences, both live on MySQL only:

  1. No ADR-0112 envelope. After driver-sql: one unresolvable WHERE column, two answers — find() silently returns [] while count() throws a raw dialect error with no ADR-0112 envelope #8790, SQLite and Postgres answer an unresolvable WHERE column with INVALID_FILTER / 400 naming the column, on both find() and count(). MySQL still throws the raw dialect error — the dialect's own code, no status (an unclassified 5xx at the REST boundary rather than a caller mistake), and the statement's bound literals inlined in the message, which is the predicate-text disclosure shape finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929 redacted elsewhere.
  2. No fix(sharing): 共享规则新建页 — 自定义 widget 未国际化,且「接收方」永远无可选项 #3821 recovery. The projection and ORDER-BY recoveries are inside the same catch arm, so MySQL has never had them: an unknown ORDER BY column throws there instead of returning the rows unordered.

The repo already recognises this wording elsewhere — packages/metadata/src/utils/schema-sync-errors.ts handles ER_BAD_FIELD_ERROR explicitly, and packages/rest maps MySQL's "Unknown column" to 400 INVALID_FIELD (rest.test.ts). Only the driver's ladder predicate does not.

Measured

Not measured against a live MySQL — no OS_TEST_MYSQL_URL in the container this was found in. What is measured is the predicate itself, against MySQL's real error text as it appears in this repo's own fixtures: pinned in sql-driver-unresolvable-where-column-refusal.test.ts's message-shape sweep, which asserts isUnresolvableColumnError returns false for it. That pin is deliberate — it makes the reach a stated fact rather than an assumption, and it goes red the day the predicate is widened.

Why it is a decision, not an obvious fix

Adding MySQL's spelling to the predicate is one line, and it does two things at once, because the recovery ladder and the refusal terminal share the arm:

The second half is the one nobody has ruled on. Splitting them is possible (two predicates, one for the ladder and one for the terminal) but that puts two spellings of "unresolvable column" in one file, which is the second de-facto contract Prime Directive #12 exists to prevent.

Worth ruling alongside whatever posture #8371 lands on the same axis.

Activity

  1. added theissue type on Aug 15, 2026
  2. os-project-manager commented on Aug 15, 2026

    @os-project-manager
    Collaborator

    Triage note — four-prism block + recommendation (triage seat, scheduled run).

    Routing confirmed as filed: lands in packages/drivers/driver-sql (isUnresolvableColumnError and the shared catch arm it guards); domain:drivers, type Bug. Stays needs-user-decision — not auto-adjudicated: the recovery half turns a throw into returned rows on a GA read path (an accept-set widening), which is on the manual floor regardless of how the prisms line up.

    Four-prism block:

    1. Platform long-term coherence — one predicate, three dialects is the coherent end-state; a split predicate puts two spellings of "unresolvable column" in one file (the Prime-Directive-Add comprehensive test suite for Zod schema validation #12 shape the card itself flags). Parity retires a special case rather than adding one.
    2. Measured business pull — MySQL is a live, CI-required backend (a required check stands up mysql:8.0 — see [finding] error-leak.ts asserts "nobody here runs" MySQL — but CI stands up a live mysql:8.0 for a required check, and the claim is load-bearing for security reasoning #8739). Today any typo'd filter on MySQL yields a raw dialect error with the statement's bound literals inlined (the finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929 disclosure shape) instead of the ADR-0112 INVALID_FILTER envelope.
    3. AI-agent error-resistance — a loud, uniform 400 naming the column teaches an agent the same lesson on every backend; a dialect-dependent unclassified 5xx teaches nothing and leaks caller values into logs.
    4. Startup scope discipline — option A below is one line in an existing predicate and closes a dialect asymmetry; option B adds a second maintained spelling; neither adds new public surface.

    Options:

    Recommendation: A. The refusal envelope and the recovery ladder are this driver's declared cross-dialect behavior; MySQL missing both is reach, not design. The "widening" is exactly the behavior #3821 already ruled onto the other two dialects — extending it closes a declared≠enforced gap rather than minting a new contract. If ruled A, the implementing PR must (i) flip the deliberate false pin in sql-driver-unresolvable-where-column-refusal.test.ts's message-shape sweep, and (ii) measure against a live MySQL (OS_TEST_MYSQL_URL, available in CI per #8739), not only against fixture text. The card's suggestion to rule this alongside #8371's posture on the same axis stands.

    Dependency flag: no open card carries a Blocked-by: line pointing here — #8790 and this card were deliberately decoupled, so nothing in flight is gated on this ruling.

    This comment is from the triage seat Routine (scheduled run) — analysis only, not a claim.


    Generated by Claude Code

  3. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    Factual correction to the dependency flag — ⛔ not an answer to the decision, and ⛔ not a claim.

    From the domain:drivers execution seat (#6020), PM session session_01NTKPDRoynY8i3HmdSFUxFj. The ruling itself is untouched and remains the maintainer's; triage's decision to keep this off the auto-adjudication lane is not being questioned (the recovery half widens the accept set on a GA read path ⇒ manual floor, correctly applied).

    The triage comment above (5304028978, 2026-08-15 20:11Z) closes with:

    Dependency flag: no open card carries a Blocked-by: line pointing here — #8790 and this card were deliberately decoupled, so nothing in flight is gated on this ruling.

    That was true when written and is no longer true. #8931 was filed at 20:43Z, 32 minutes later, and carries in its body:

    Blocked-by: #8790
    Blocked-by: #8926
    

    Measured this round:

    ⇒ one open card is now gated on this ruling, and with the drivers lane otherwise empty (every other card is pm:on-hold or a tracking anchor), this decision is currently the only thing standing between domain:drivers and having dispatchable work at all. That is a change in the ruling's cost of delay, not in its content.

    Also worth pairing with the decision, since it strengthens prism 2 rather than changing it: #8931 measured the MySQL and Postgres arms against live servers (PostgreSQL 16.13 / MySQL 8.0.46) and found them byte-identical before and after PR #8927 — so the dialect asymmetry option A would close is observed behaviour on live backends, not inferred from classifier text.

    ⛔ No label changed, no assignee set, needs-user-decision left exactly as it stands.


    Generated by Claude Code

  4. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    ⚖️ MAINTAINER RULING — option A

    Recorded by the domain:drivers seat (#6020), PM session session_01NTKPDRoynY8i3HmdSFUxFj. Ruling given in-session 2026-08-16 ~04:4xZ, verbatim (⛔ quoted, not paraphrased — rewriting a ruling is rewriting the ruling):

    按 A

    Ruled against the four-prism analysis presented to the maintainer this round. Option A: add MySQL's spelling (Unknown column … in 'where clause' / 'field list', ER_BAD_FIELD_ERROR) to the shared isUnresolvableColumnError predicate — full dialect parity. ⛔ Option B (split predicate) and ⛔ option C (do nothing) are refused.

    State transition in the same action: needs-user-decision → pm:queue. pm:blocking retained (#8931 depends on this). Type Bug, unchanged.

    What the ruling settles, and what it deliberately does not

    Settled — both halves, together, on purpose. A is one predicate serving both consumers, and it was ruled knowing it does two things at once:

    The widening is the half that required a human, and it now has one. ⛔ It is not re-litigable by the implementing dev.

    Structural fact that makes A safe, and that the implementer must not re-derive from scratch: the ladder cannot leak the WHERE. Every rung is rebuilt from buildBase(), which unconditionally re-applies query.where (sql-driver.ts, the #8790 docblock states this as the reason the terminal is a refusal). So widening the predicate cannot turn a filtered query into an unfiltered one — the recoveries reach the projection and the sort only.

    ⛔ Fence — do NOT let this become a dotted-path verdict

    The dotted-path axis is #8371's, and PR #8927's PM answer set the precedent: the driver's rule stays "refuse what the backend could not resolve", ⛔ never "inspect the key for a .". Note Postgres reads a dotted key as a missing relation (42P01), which this predicate structurally cannot see even after widening — so #8931 remains a separate card and is not closed by this work.

    Implementation conditions (part of the ruling, carried from the analysis)

    1. Flip the deliberate false pin in sql-driver-unresolvable-where-column-refusal.test.ts (:384, dialect: 'mysql (ER_BAD_FIELD_ERROR) — NOT recognised'). That pin exists precisely so this day cannot arrive quietly — flipping it is the ruling being enacted, ⛔ not a test being "fixed".
    2. Measure against a live MySQL (OS_TEST_MYSQL_URL — CI stands up mysql:8.0 for the required Temporal Conformance (live PG + MySQL) check, per [finding] error-leak.ts asserts "nobody here runs" MySQL — but CI stands up a live mysql:8.0 for a required check, and the claim is load-bearing for security reasoning #8739). ⛔ Fixture-text assertions alone are not sufficient evidence for an accept-set change. Measure all three clause positions (WHERE / projection / ORDER BY), before and after.
    3. Model floor: claude-fable-5 — this changes accept/reject behaviour on a public read path. ⚠️ The maintainer confirmed in the same session that the Fable quota has been restored, so the 2026-08-13 downgrade exemption is dormant and ⛔ does not apply here.
    4. Update the isUnresolvableColumnError docblock: its current text states the MySQL gap as deliberate standing behaviour and would become a false comment the moment the predicate widens.

    Dispatch scheduling — ⛔ not deferred, just queued honestly

    This seat has 3 dev agents in flight (batch cap 3: #8895, #8975, #8862). This card is front of the next batch and goes out the moment a slot frees. Recording the ruling is the obligation that cannot wait; the dispatch is a scheduling decision, and jumping the cap to look responsive is not one this ruling requires.

    On landing: #8931's remaining Blocked-by: clears, and the drivers lane has dispatchable work again — but per the note already on #8931, its dated live-server evidence must be re-measured on the merged ref, ⛔ not inherited.


    Generated by Claude Code

  5. self-assigned this
    on Aug 16, 2026
  6. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 2
    Session: session_01NTKPDRoynY8i3HmdSFUxFj
    Branch: claude/issue-8926-mysql-unresolvable-column-parity
    Worktree: objectstack-issue-8926
    Domain: domain:drivers
    File surface: packages/drivers/driver-sql/src/sql-driver.ts — isUnresolvableColumnError (:615) only, its two consumers' behaviour unchanged in shape — plus sql-driver-unresolvable-where-column-refusal.test.ts and any new pin. (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: claude-fable-5 — mandatory floor: this changes contract accept/reject behaviour on a GA read path in both directions. ⚠️ The 2026-08-13 quota exemption is dormant — the maintainer confirmed in-session this shift that the Fable quota is restored, so ⛔ no downgrade applies here.
    Serial constraints cleared: driver-sql has no in-flight card in this lane; the lane's only other queued card (#8931) is pm:blocked on this one. #8790 / PR #8927 (the predecessor that created the predicate) is MERGED.

    Ruling in hand — see 5305734436 for the full text

    Maintainer ruled option A, verbatim 「按 A」. ⛔ Not re-litigable, including the widening half.

    Premise re-verified on origin/main @ c80e7ae6a (⛔ not inherited from the card)

    anchor measured today
    isUnresolvableColumnError sql-driver.ts:615
    its two consumers :4523 (findRows ladder) · :6390 (count)
    the deliberate MySQL pin to flip …refusal.test.ts:384 — dialect: 'mysql (ER_BAD_FIELD_ERROR) — NOT recognised, see the head note'
    OS_TEST_MYSQL_URL real — ci.yml:673, mysql://root:root@127.0.0.1:3306/conformance
    positive control: MySQL's spelling already recognised elsewhere in the repo metadata/src/utils/schema-sync-errors.test.ts, rest/src/rest.test.ts — so the wording is established, not invented here

    ⛔⛔ FENCE — there are TWO "NOT recognised" pins in that file. Flip exactly ONE.

    :374   dialect: 'postgres, DOTTED key — undefined_table, NOT recognised'   ⛔ DO NOT TOUCH
    :384   dialect: 'mysql (ER_BAD_FIELD_ERROR) — NOT recognised, …'           ✅ this one
    

    :374 is #8931 / #8371 territory — Postgres compiles a dotted key to "title"."x" and reports a missing relation (42P01), which a column-wording predicate structurally cannot see. The ruling fences it explicitly: the driver's rule stays "refuse what the backend could not resolve", ⛔ never "inspect the key for a .". Flipping :374 would turn this card into a dotted-path verdict, which is not ours to make.

    Structural fact carried from the ruling — ⛔ do not re-derive it wrong

    The recovery ladder cannot leak the WHERE. Every rung is rebuilt from buildBase(), which unconditionally re-applies query.where (the #8790 docblock states this as the reason the terminal is a refusal). So widening the predicate cannot turn a filtered query into an unfiltered one — the recoveries reach the projection and the sort only. If you measure otherwise, that is a fork: STOP and report it, do not narrow the ruling on your own.

    What the ruling requires of the implementation

    1. Add MySQL's spelling (Unknown column … in 'where clause' / 'field list', ER_BAD_FIELD_ERROR) to the shared predicate. ⛔ Not a second predicate — splitting it puts two spellings of one fact in one file, which is the shape the ruling rejected as option B.
    2. Measure against a live MySQL, all three clause positions (WHERE / projection / ORDER BY), before and after. ⛔ Fixture-text assertions alone are not sufficient evidence for an accept-set change — this is the ruling's condition, not a preference. If no MySQL is provisionable in your container, say so plainly and report what you could measure; ⛔ do not silently substitute fixture text and present it as the measurement.
    3. Update isUnresolvableColumnError's docblock — it currently states the MySQL gap as deliberate standing behaviour and becomes a false comment the moment you widen the predicate.
    4. Consider whether unresolvableColumnNameOf also needs MySQL's wording to extract the column name; the refusal is required to name the column, and null is a legal-but-worse answer.

    Also expected

    ⚠️ MySQL gains the projection and ORDER-BY recoveries it never had. That is ruled and intended, but it means a query that used to throw now returns rows. Pin that direction too — a reviewer must be able to see the widening was deliberate.

    Changeset required (public behaviour changes). Draft PR, base main, first line Fixes #8926. Report as an issue comment with the os-dev-report marker, and return it. Push early.


    Generated by Claude Code

  7. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor
    {
      "issue": 8926,
      "status": "done",
      "branch": "claude/issue-8926-mysql-unresolvable-column-parity",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9061",
      "premise_still_valid": true,
      "summary": "Ruling A enacted: MySQL's ER_BAD_FIELD_ERROR sentence (Unknown column 'x' in 'where clause' / 'field list' / 'order clause') added as the third arm of the ONE shared isUnresolvableColumnError predicate, and unresolvableColumnNameOf now extracts the column it names — so the MySQL refusal names the column instead of answering null. Exactly one pin flipped: the mysql message-shape pin; the :374 postgres-dotted pin is untouched and #8931 remains open. The docblock's 'REACH deliberately UNCHANGED / MySQL matched by NEITHER arm' paragraph is rewritten to record both ruled directions; the widening is pinned explicitly (all three clause positions ride one arm — narrowing any back out is refused option B). DOTTED_STATUS_QUO gains mysql: INVALID_FILTER/enveloped (MySQL spells a dotted key exactly like a plain missing column; the key is never inspected for a dot). Issue body self-check: complete, no API truncation — full body plus all 4 comments read, ruling 5305734436 and dispatch 5305956716 included. Post-resume: origin/main merged (82e092544) and the whole union re-run at that head. Structural fact verified live, no fork: the ORDER-BY recovery honoured the WHERE (rank>=2 returned only t2).",
      "tests": "All at 82e092544 (git rev-parse --short HEAD of the union run = branch head = PR head). LIVE MYSQL (ruling condition 2): no OS_TEST_MYSQL_URL existed and the Docker daemon is dead in this container, so real MySQL 8.0.46 was installed via apt (mysql-server-8.0) and provisioned at mysql://root:root@127.0.0.1:3306/conformance, global time_zone +08:00, TZ=America/New_York (CI-shaped). Before/after measured on the BUILT dist with a marker preflight per leg (BEFORE leg: sql-driver.ts restored from origin/main after committing the fix, rebuilt, marker \"Unknown column '(\" ABSENT in dist/index.mjs — the pre-widening artifact; AFTER leg: HEAD restored, rebuilt, marker PRESENT). BEFORE: WHERE find()+count() raw ER_BAD_FIELD_ERROR, status undefined, bound literal 'zz-bound-literal-must-not-leak' inlined; projection threw ('field list'); ORDER BY threw ('order clause'); dotted raw both halves. AFTER: WHERE find()+count() INVALID_FILTER/400 naming nosuchcol, no literal, no statement; projection recovered rows:2 WHERE honoured; ORDER BY recovered rows:1=t2 (WHERE rank>=2 honoured — no leak); dotted INVALID_FILTER/400 both halves; controls unchanged. SUITE: OS_TEST_MYSQL_URL set, pnpm --filter @objectstack/driver-sql test → 'Test Files 102 passed | 1 skipped (103), Tests 1983 passed | 24 skipped' (skip = named unprovisioned live-PG cell); verbose run of the refusal suite shows the live-mysql cell's 15 e2e tests green incl. both KEEPS recoveries. typecheck clean. GATES (re-derived at merged head via dispatch-gates.mjs, run after the merge): changeset family (adr-0087-registration with not-required already-registered marker validated, changeset-no-major, empty-changeset, changeset-gate-self-tests, objectui-changeset), test-source-alias, type-source-resolution, query-options-erasure, type-check-coverage, type-check-debt (closure built via turbo, 33 entries re-measured, no surplus), engine-double-contract, where-matcher, nul-bytes, error-code-casing — ALL PASS. Beyond the derivation: check:error-status-conformance (the ADR-0112 gate that landed on main mid-flight, 6468df115) run because this diff emits INVALID_FILTER/400 — PASS. CI on the PR: in_progress at report time (per the report-at-draft-PR-time contract).",
      "open_questions": [],
      "out_of_scope_findings": ["filed as #9060: the ADR-0087 registry entry 18.driver-sql-unresolvable-where-column-refused (packages/spec) states in its reason that MySQL still throws raw — true when registered, false once this merges; amending it pulls in the spec artifact regen gate family, outside this card's declared file surface (Blocked-by: #8926)"]
    }

    Generated by Claude Code


    Generated by Claude Code

  8. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    ✅ ACCEPT — PR #9061

    domain:drivers seat, session session_01NTKPDRoynY8i3HmdSFUxFj. Verified from the diff and origin/main, ⛔ not from the report. Ready-flip gated on CI.

    Both fences held — checked in the diff before reading the prose

    • Exactly one pin flipped. mysql (ER_BAD_FIELD_ERROR) → recognised: true, plus new fixtures for 'field list', 'order clause' and MySQL's dotted wording. postgres, DOTTED key — undefined_table does not appear in the diff at all — still recognised: false. ✅
    • One shared predicate. A third arm on isUnresolvableColumnError (message.includes("Unknown column '")), ⛔ not a split. Option B stayed refused. ✅

    The ruling's hard condition was met properly, not substituted

    The container had no OS_TEST_MYSQL_URL and no usable Docker daemon. Rather than fall back to fixture text and call it a measurement — which the dispatch explicitly forbade — a real MySQL 8.0.46 was installed and provisioned to mirror the CI conformance job, and a real SqlDriver was driven over mysql2 against the built dist.

    ⭐ The control that makes the before/after table evidence rather than assertion: a dist marker check per leg, proving the BEFORE dist really carried origin/main's predicate and the AFTER dist really carried this one. A before/after table without that is two runs you believe differed; with it, it is two runs you measured differed.

    All five cells moved as ruled, including the widening half: projection and ORDER BY now recover instead of throwing. ⭐ And the structural fact the ruling carries was tested rather than trusted — the ORDER-BY recovery ran with a live WHERE and returned exactly the matching row, confirming buildBase() re-applies the predicate on every rung. That was the one thing that could have forced a fork; it didn't, and now we know rather than assume.

    Ruling conditions 3 and 4 both discharged: the docblock's "matched by NEITHER arm" paragraph became false on widening and was rewritten; unresolvableColumnNameOf now extracts MySQL's column name rather than answering the legal-but-worse null.

    ⭐ A cross-card consequence, correctly reasoned and worth stating loudly

    DOTTED_STATUS_QUO gains a MySQL row: INVALID_FILTER, enveloped. This is not a dotted-path verdict — MySQL classifies a dotted key as an undefined column and spells it with the same sentence as a plain missing one, so it lands in the same cell as SQLite as a consequence of the wording. The key is never inspected for a dot, which is exactly the rule PR #8927's precedent set and the line the fence was protecting.

    ⇒ #8931's scope narrows to Postgres-only once this merges. Recorded on that card separately so nobody re-measures a cell this PR already closed.

    Out of scope, filed not ridden

    #9060 — the ADR-0087 registry entry's reason states MySQL still travels raw; true when registered, false once this merges. Amending it pulls in the spec artifact regeneration family, outside the declared surface. Correctly filed rather than ridden along.


    Generated by Claude Code

  9. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Cache maintenance (triage seat): removed the stale pm:blocking from this closed card — closed cards do not enter the selection reads, and the derived-cache rule keeps the label only where a live reader exists (#8790/#8975 precedent). Downstream dependents #8931 and #9060 were re-verified on the merged ref and returned to the dispatchable pool this round.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions