Skip to content

fix(driver-sql): attribute an unresolvable distinct() column to the clause the caller named - #17945

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-17857-distinct-column-attribution
Sep 13, 2026
Merged

fix(driver-sql): attribute an unresolvable distinct() column to the clause the caller named#17945
os-musk merged 4 commits into
mainfrom
claude/issue-17857-distinct-column-attribution

Conversation

@os-musk

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

Copy link
Copy Markdown
Collaborator

Fixes #17857

Clause-②: no — the call is refused either way; what changes is the refusal's envelope and attribution. No input that was previously refused becomes accepted, and no exported symbol moves.

distinct() was the last read door answering a server fault for one unresolvable column. This lands the attribution arm #17639 deliberately left filed, in the shape #11541 established for aggregate().

The five rows, run rather than reasoned about

Embedded SQLite, one object with a single title column, one column name the table does not have. Before = origin/main at dbea1756d9; after = this branch at 15f26e6210, where the full package suite, the repo lint and the derived gate set were all re-run.

door before after arm
count(t, { where: { nosuchcol: 1 } }) INVALID_FILTER / 400 unchanged (not this door)
find(t, { where: { nosuchcol: 1 } }) INVALID_FILTER / 400 unchanged (not this door)
aggregate(t, { groupBy: ['nosuchcol'] }) INVALID_FIELD / 400 unchanged (not this door)
distinct(t, 'title', { nosuchcol: 1 }) DATABASE_ERROR / 500 INVALID_FILTER / 400 arm 2 (a predicate named it)
distinct(t, 'nosuchcol') DATABASE_ERROR / 500 INVALID_FIELD / 400 arm 1 (the listed field)

After-row 4 is byte-identical to what find() and count() answer for the same filter — asserted as message equality, not merely as the same code, because a drifted wording is how one condition acquires two explanations. After-row 5 names the listed column and the word filter appears nowhere in it.

The three arms

SqlDriver.distinctBackendFault reads the dialect-named column back against the caller's own request. The dialect names the column; it does not name the clause.

  1. the name equals the field argumentINVALID_FIELD / 400 naming the listed column, carrying the field and object riders the ingress door's refusals carry;
  2. it does not ⇒ the statement's only remaining column sources are the WHERE compiled from filters and the tenant-scope predicate, both filters, so driver-sql: one unresolvable WHERE column, two answers — find() silently returns [] while count() throws a raw dialect error with no ADR-0112 envelope #8790's existing unresolvableFilterColumnRefusal applies verbatim;
  3. the wording yields no name ⇒ no attribution is supportable and driver-sql: distinct() leaks the raw backend error with status undefined — the ADR-0112 envelope #11455 gave aggregate() never reached the third read door #17639's terminal DATABASE_ERROR / 500 stands unchanged.

No blanket isUnresolvableColumnError arm: the classifier is what makes that claim unnecessary, not what makes it safe. No code is minted and no new dialect recognizer is added — both predicates are the ones find(), count() and aggregate() already share.

One deliberate departure from the dispatch brief, with the measurement behind it

The brief paraphrased arm 2 as "the name appears in the filters AST" with "neither" falling to the terminal. The card's own text is the #11541 mirror — arm 2 is the complement of arm 1 — and that is what landed, because the two designs are distinguishable and one of them re-opens the defect. See ablation leg C below. Both designs produce the identical five rows above, so the divergence is invisible to the card's own probe.

Ablation — three legs, each with on-disk proof and a verified restore

The fix was committed first; every leg mutated the committed file, proved the mutation reached disk by marker count and by git hash-object differing from the HEAD blob, ran the suite, restored with git checkout HEAD -- PATH, and proved the restore by the on-disk blob hash equalling the HEAD blob again with git diff HEAD empty.

leg mutation result
A delete arm 1 (i.e. the forbidden blanket arm) 6 failed / 12 passed — every arm-1 case, the five-row case and the tie-break
B delete arm 2 (every non-field column falls to the terminal) 6 failed / 12 passed — every arm-2 case, both dialect-wording units and the five-row case
C replace the complement with a top-level filters key search 3 failed / 15 passed

Leg C is the discriminating one. The flat-filter case (arm 2: a filter column gets byte-identical treatment at all three doors) stayed green, while the nested case failed:

AssertionError: $or: code: expected 'DATABASE_ERROR' to be 'INVALID_FILTER'

FilterCondition nests, so an AST search silently downgrades { $or: [{ nosuchcol: 1 }] } back to the 500 this card removes — the defect itself, one nesting level down, invisible to any pin written on a flat filter. The complement needs no walker to be complete: the compiled statement is select distinct FIELD from TABLE where FILTERS-PLUS-TENANT-SCOPE, so a column reference that is not the field came from a predicate.

Marker hashes, for the record: HEAD blob 6b7457d056; leg A 18c0dcaf80, leg B ee90517fce, leg C abcb2146c3; all three restored to 6b7457d056 with a residual marker count of 0.

Changeset — measured, not assumed: patch

The expectation was patch; the measurement confirms it rather than being taken from the sibling's precedent. @objectstack/driver-sql declares files: ["dist","README.md","CHANGELOG.md"], so dist/ was built and grepped with both controls:

reading result
positive control aggregateBackendFault (published since #11541) hits dist/index.js, dist/index.mjs, dist/index.d.ts, dist/index.d.mts
this change's marker distinctBackendFault hits the same four published files
this change's refusal sentence lists the distinct values of hits dist/index.js, dist/index.mjs
fabricated negative control distinctBackendFaultNeverShipped 0
test-only symbol ClassifierProbe 0

A protected member does reach the published declaration:

3819:    protected distinctBackendFault(object: string, field: string, error: unknown): Error;
3860:    protected unresolvableDistinctColumnRefusal(object: string, column: string, error: unknown): Error;

Published bytes move ⇒ patch, never skip-changeset.

Verification

  • pnpm --filter @objectstack/driver-sql testexit 0, 177 files passed / 11 skipped, 2620 tests passed / 166 skipped. The skips are the live PG and MySQL cells, unprovisioned here and covered by the Temporal Conformance (live PG + MySQL) job.
  • pnpm --filter @objectstack/driver-sql typecheckexit 0.
  • pnpm --filter '@objectstack/driver-sql^...' buildexit 0 (dependency closure).
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, clean, no narrowing declared.
  • Gate families derived from the diff with scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 62 derived, 62 run, 59 exit 0, re-run in full at the final HEAD. The three non-zero are all PREREQUISITE NOT MET / exit 3 — NOT MEASURED, neither pass nor failure — because they read built output for the whole workspace: check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt. CI builds the workspace before those steps.
  • check:doc-authoring caught a real defect in this diff's first draft — the new logger.warn string carried (#7929, #17857), and a log line reaches operators and generated surfaces where an issue id resolves against nothing. The ids moved to the adjacent comment; the gate is green.

Acceptance notes


Generated by Claude Code

…lause the caller named

`distinct()` was the last read door answering DATABASE_ERROR / 500 for one
unresolvable column, where find()/count() answer INVALID_FILTER / 400 and
aggregate() answers INVALID_FIELD / 400. #17639 landed the terminal envelope
here and deliberately left the attribution arm filed: a blanket
isUnresolvableColumnError arm would tell the author of distinct(o, 'nosuchcol'),
who passed no filter at all, that their filter was wrong.

distinctBackendFault reads the dialect-named column back against the caller's
own request, the shape #11541 established for aggregate(): the name equals the
field argument => INVALID_FIELD / 400 naming the listed column; it does not =>
the only remaining column sources are the WHERE and the tenant-scope predicate,
so #8790's filter refusal applies verbatim; no parsed name => the #17639
terminal stands.

Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM
Co-authored-by: Claude <noreply@anthropic.com>
Its readings are recorded in the PR body and in the pin suite's head note;
the suite's own 'all four read doors' case asserts the same five rows.

Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM
Co-authored-by: Claude <noreply@anthropic.com>
…he nested-filter pin table

check:doc-authoring reds on a new (file,id) pair in sibling-package string
prose: a runtime log line reaches operators and generated surfaces where
'#NNNN' resolves against nothing. Ids moved to the adjacent comment.

Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 3 documentable anchor(s).

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

  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class))

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

  • content/docs/releases/v17/17-0.mdx (via SqlDriver (symbol, a top-level class))

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
  • 1 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 — 11 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 bd25e897dc3cf9cf50af9dd23a7d36948ade6bd9packageMentionDocs.

Which tree this was computed on

This run read content/docs from 601198f3615219085396b8ddb93654c8d0fe17ba — the merge of head 15f26e6210ee49f2eba2ea575ede958784e6a6f3 into base bd25e897dc3cf9cf50af9dd23a7d36948ade6bd9, 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 601198f3615219085396b8ddb93654c8d0fe17ba && git checkout 601198f3615219085396b8ddb93654c8d0fe17ba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bd25e897dc3cf9cf50af9dd23a7d36948ade6bd9 15f26e6210ee49f2eba2ea575ede958784e6a6f3 && git checkout -B drift-repro bd25e897dc3cf9cf50af9dd23a7d36948ade6bd9 && git merge --no-ff 15f26e6210ee49f2eba2ea575ede958784e6a6f3

node scripts/docs-audit/affected-docs.mjs --json bd25e897dc3cf9cf50af9dd23a7d36948ade6bd9

⚠️ 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 bd25e897dc3cf9cf50af9dd23a7d36948ade6bd9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-musk commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Head reviewed: 15f26e6210. Card #17857 · PR #17945 · domain:engine 执行 PM 席 · R2 · session_01RuoNSXUbBoWHkNS4AknTrM · 2026-09-13。
Tier: 默认判断档自审加门禁。合并基 git merge-base = dbea1756d9(⭐ 现算,⛔ 不用 pull.base.sha)。
⛔ 本席未采信 dev 的任何承重读数。⚠️ 本轮最重要的一条是:dev 偏离了本席的派发令,并且它是对的 —— 见 ③。

① derived judgments

# 问题 本席读数
1 分类器是否公开面 protected distinctBackendFault(...)(sql-driver.ts:9742)—— 与判例 aggregateBackendFault(:9443)同一成员种类 ⭕ 非公开面
2 接受集是否移动 三条臂全部落在已被拒的调用上;变的是信封与归因(500/DATABASE_ERROR → 400/INVALID_FIELDINVALID_FILTER)。⛔ 无「原本被拒的现在被接受」 Clause-②: no 判对
3 是否铸了新错误码 ⛔ 否 —— INVALID_FIELD 是标准目录成员,且已是本仓对「具名列不存在」的答案(assertGroupByFieldsExist、写路径、以及 unresolvableAggregateColumnRefusal) ⭕ 合 ADR-0112
4 是否加了 blanket 归因臂(#8931 禁止的那种) ⛔ 否 —— arm 1 先把 field 摘出去,distinct(o,'nosuchcol') 拿到的是 INVALID_FIELD消息里不含 filter 一词
5 闸门 check-clause2-carriers --pair 17945 exit 0(申报可读、双肢一致、⛔ 无放宽 tell)。⚠️ 该申报行写成 no — … 带破折号续写,本席没有靠肉眼判它可不可读,而是跑了闸门
6 被证伪的头注是否同笔修掉 sql-driver-17639-…-envelope.test.ts 的头注原说 distinct 归因半边「is filed as its own card」—— 本 PR 使其为假,已在同一 diff 改写并点名新 pin ⭕ ⭐ 这正是本班次反复出现的那类缺陷(#17409 / #16917 的自指假陈述),这次是交付方自己先发现的

② semver 定级

@objectstack/driver-sql patch。dev 的测量证实而非推翻本席的预期(⭐ 本班次头一次):阳性对照 aggregateBackendFault 命中 dist 四件产物;本次标记 distinctBackendFault 命中同样四件 ⇒ 一个 protected 成员确实进入已发布声明;捏造对照 distinctBackendFaultNeverShipped0;测试专用符号 ClassifierProbe0。⇒ 有已发布字节移动 ⇒ ⛔ 不是 skip-changeset;公开面无增长 ⇒ ⛔ 不是 minor

③ 边界旗处置 —— ⭐ 裁定 open question:A(已落地的补集),⛔ 不是本席派发令里的 B

dev 提的问题:arm 2 落成了 arm 1 的补集(非 field 即归因于谓词),而本席的派发令把它写成正向搜 filters AST、两者皆非落终局。两者只在卡面五行之外分岔。

本席独立复核,裁 A,理由是测量不是偏好:

  1. ⭐⭐ 判例逐条同形。 本席读了卡面点名要照搬的 aggregateBackendFault(:9443):它对调用者列出的字段(groupBy / aggregations)做正向判断,else 直接 return this.unresolvableFilterColumnRefusal(object, error) —— 就是补集,else 之后才是「无解析名 ⇒ 终局」。⇒ 本席派发令里的 B 才是偏离判例的那个,A 是与 driver-sql: an unresolvable WHERE column on aggregate() answers DATABASE_ERROR/500 where find() and count() answer INVALID_FILTER/400 — the #8790 refusal never reached the third read door #11541 一致。
  2. 补集在本门按构造是安全的。 distinct() 建句只放三处列引用:builder.distinct(field)filters 编译出的 WHERE、以及该方法自己加的租户隔离谓词。arm 1 摘掉 field 之后,剩下的确实只有谓词 ⇒ 归因不是猜测。
  3. dev 的消融腿 C 是可鉴别的。 它把本席的 B 实现出来跑:扁平 filter 的对照全绿,而 { $or: [{ nosuchcol: 1 }] } 退回 DATABASE_ERROR 500 —— 本卡要修的缺陷,在嵌套一层处原样复发,且扁平 pin 看不见。⇒ B 会把 driver-sql: one unresolvable WHERE column, two answers — find() silently returns [] while count() throws a raw dialect error with no ADR-0112 envelope #8790 裁决(「一个不可解析列,一个答案」)在嵌套 filter 与租户列上重新打破

⇒ ⛔ 这不需要上呈维护者:它不是新契约决定,而是 #8790 既有裁决 + #11541 既有形状的应用。dev 把它标成「maintainer may want to confirm」是稳妥的,本席据以上三条把它在席内裁掉

⚠️⚠️ 本席的过失记在这里: 派发令第三节把三条臂写成「name 在 filters AST 里 / 两者皆非」,那是本席没有先读判例正文就转述的。⇒ 与本班次另四次同类(#17621 / PUT 预防 / objectui false green / population floors)合为第五次。⭐ 但这次 dev ⛔ 没有照做、也⛔ 没有默默改掉,而是实现了正确的那个、把本席的那个做成消融腿证伪、并当作 open question 交回 —— 这正是「停止条件写成析取式并允许 dev 反驳前提」要买到的东西。

其余两条 noted, not filed 处置:

独立性对

Implemented-by: claude/issue-17857-distinct-column-attribution
Reviewed-by: session_01RuoNSXUbBoWHkNS4AknTrM

判词

PASS。 ①②③ 无欠改,open question 已在席内裁定为 A(即已落地形态,⛔ 无需改动)。落地前检:① 本记录;② --pair 17945 exit 0;③ 待 CI 全绿(⛔ 全部 check,非 required 子集)。


Generated by Claude Code

@os-musk
os-musk marked this pull request as ready for review September 13, 2026 07:48
@os-musk
os-musk enabled auto-merge September 13, 2026 07:48
@os-musk
os-musk added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 9ccc417 Sep 13, 2026
36 checks passed
@os-musk
os-musk deleted the claude/issue-17857-distinct-column-attribution branch September 13, 2026 08:17
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/l tests tooling

Projects

None yet

2 participants