Repository navigation
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
Activity
os-project-manager commented
on Aug 15, 2026 CollaboratorMore actionsTriage note — four-prism block + recommendation (triage seat, scheduled run).
Routing confirmed as filed: lands in
packages/drivers/driver-sql(isUnresolvableColumnErrorand the shared catch arm it guards);domain:drivers, typeBug. Staysneeds-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:
- 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.
- Measured business pull — MySQL is a live, CI-required backend (a required check stands up mysql:8.0 — see [finding]
error-leak.tsasserts "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-0112INVALID_FILTERenvelope. - 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.
- 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:
- A. Add MySQL's spelling (
Unknown column … in 'where clause' / 'field list',ER_BAD_FIELD_ERROR) to the shared predicate — MySQL gets both the driver-sql: one unresolvable WHERE column, two answers —find()silently returns [] whilecount()throws a raw dialect error with no ADR-0112 envelope #8790 refusal envelope and the fix(sharing): 共享规则新建页 — 自定义 widget 未国际化,且「接收方」永远无可选项 #3821 projection/ORDER-BY recoveries (full dialect parity). - B. Split the predicate — envelope for MySQL, recoveries stay SQLite/Postgres-only (two spellings of the same fact in one file).
- C. Do nothing.
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
falsepin insql-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
Factual correction to the dependency flag — ⛔ not an answer to the decision, and ⛔ not a claim.
From the
domain:driversexecution seat (#6020), PM sessionsession_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: #8926Measured this round:
- driver-sql: one unresolvable WHERE column, two answers —
find()silently returns [] whilecount()throws a raw dialect error with no ADR-0112 envelope #8790 is discharged — closed completed, PR fix(driver-sql): refuse an unresolvable WHERE column on both find() and count() (#8790) #8927 MERGED. - 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 (this card) is therefore A dotted WHERE key escapes as a raw dialect error with the bound literal inlined on Postgres and MySQL — pre-existing, and measured against live servers #8931's sole remaining blocker.
⇒ one open card is now gated on this ruling, and with the drivers lane otherwise empty (every other card is
pm:on-holdor atrackinganchor), this decision is currently the only thing standing betweendomain:driversand 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-decisionleft exactly as it stands.
Generated by Claude Code
- driver-sql: one unresolvable WHERE column, two answers —
⚖️ MAINTAINER RULING — option A
Recorded by the
domain:driversseat (#6020), PM sessionsession_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 sharedisUnresolvableColumnErrorpredicate — 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:blockingretained (#8931 depends on this). TypeBug, 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 narrowing: MySQL gains the ADR-0112
INVALID_FILTER/ 400 envelope naming the column, and the bound literals stop travelling to the caller (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, still live on MySQL today); - the widening: MySQL gains the fix(sharing): 共享规则新建页 — 自定义 widget 未国际化,且「接收方」永远无可选项 #3821 projection and ORDER-BY recoveries it has never had — a throw becomes returned rows on a GA read path.
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-appliesquery.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)
- Flip the deliberate
falsepin insql-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". - Measure against a live MySQL (
OS_TEST_MYSQL_URL— CI stands upmysql:8.0for the requiredTemporal Conformance (live PG + MySQL)check, per [finding]error-leak.tsasserts "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. - 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. - Update the
isUnresolvableColumnErrordocblock: 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
- the narrowing: MySQL gains the ADR-0112
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 — plussql-driver-unresolvable-where-column-refusal.test.tsand 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-sqlhas no in-flight card in this lane; the lane's only other queued card (#8931) ispm:blockedon this one. #8790 / PR #8927 (the predecessor that created the predicate) is MERGED.Ruling in hand — see
5305734436for the full textMaintainer 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 isUnresolvableColumnErrorsql-driver.ts:615its two consumers :4523(findRowsladder) ·: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_URLreal — ci.yml:673,mysql://root:root@127.0.0.1:3306/conformancepositive 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:374is #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:374would 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-appliesquery.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
- 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. - 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.
- 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. - Consider whether
unresolvableColumnNameOfalso needs MySQL's wording to extract the column name; the refusal is required to name the column, andnullis 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 lineFixes #8926. Report as an issue comment with theos-dev-reportmarker, and return it. Push early.
Generated by Claude Code
- Add MySQL's spelling (
{ "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
✅ ACCEPT — PR #9061
domain:driversseat, sessionsession_01NTKPDRoynY8i3HmdSFUxFj. Verified from the diff andorigin/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_tabledoes not appear in the diff at all — stillrecognised: 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_URLand 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 realSqlDriverwas 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;
unresolvableColumnNameOfnow extracts MySQL's column name rather than answering the legal-but-worsenull.⭐ A cross-card consequence, correctly reasoned and worth stating loudly
DOTTED_STATUS_QUOgains 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
reasonstates 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
- Exactly one pin flipped.
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsCache maintenance (triage seat): removed the stale
pm:blockingfrom 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
- added a commit that references this issue
on Aug 16, 2026 - added a commit that references this issue
on Sep 28, 2026
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
SqlDriverdecides "is this an unresolvable column?" with one predicate (isUnresolvableColumnError, extracted from the inline test the #3821 ladder carried):MySQL spells the same condition
Unknown column 'x' in 'where clause'/'field list'(ER_BAD_FIELD_ERROR). It containscolumnbut notdoes not exist, so neither arm matches. Two consequences, both live on MySQL only:find()silently returns [] whilecount()throws a raw dialect error with no ADR-0112 envelope #8790, SQLite and Postgres answer an unresolvable WHERE column withINVALID_FILTER/ 400 naming the column, on bothfind()andcount(). MySQL still throws the raw dialect error — the dialect's owncode, nostatus(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.catcharm, 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.tshandlesER_BAD_FIELD_ERRORexplicitly, andpackages/restmaps MySQL's "Unknown column" to400 INVALID_FIELD(rest.test.ts). Only the driver's ladder predicate does not.Measured
Not measured against a live MySQL — no
OS_TEST_MYSQL_URLin 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 insql-driver-unresolvable-where-column-refusal.test.ts's message-shape sweep, which assertsisUnresolvableColumnErrorreturnsfalsefor 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:
INVALID_FILTERenvelope — an accept-set narrowing, the direction driver-sql: one unresolvable WHERE column, two answers —find()silently returns [] whilecount()throws a raw dialect error with no ADR-0112 envelope #8790 was ruled on;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.