Skip to content

fix(driver-sql,driver-turso): a cross-field $field refusal stops naming the two columns it compared (#7929, #7988) - #8198

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-7929-withhold-field-operands
Aug 12, 2026
Merged

fix(driver-sql,driver-turso): a cross-field $field refusal stops naming the two columns it compared (#7929, #7988)#8198
os-zhuang merged 2 commits into
mainfrom
claude/issue-7929-withhold-field-operands

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #7929
Fixes #7988

Implements B of the maintainer ruling of 2026-08-12 on #7929 (comment 5266067250), verbatim provenance 「接受你的全部建议。」 — adopting "B now, A next". driver-sql's INVALID_FILTER refusal stops echoing $field operands for every caller; the full diagnostic goes to the server-side log.

Because the withhold lands at the consumer, it covers both merge boundaries at once — the analytics read-scope merge (service-analytics) and the ordinary CRUD read (plugin-security), which is why #7988 closes here too. Both are pinned; see "Pins" below.

What a caller gets now, and what moved

code: INVALID_FILTER and status: 400 are unchanged, and the set of refused filters is unchanged — verified operator by operator, including the six scalar comparisons that still compile.

Withheld (moved to the log): the referenced column, the target column, the operator the filter used, the list index, and the boundary reason — the sentence that named the object, the declared types and, on the tenant arm, which column is the tenant-isolation column.

Kept on the wire: the envelope, which of the three cross-field refusal classes fired (position / bare form / boundary), and the capability statement — same-table declared columns, same type class, tenant-isolation column excluded. None of it is derived from what was sent. Example:

A cross-field comparison ({ "$field": … }) in this filter cannot be compiled here. Cross-field comparison on SQL push-down supports same-table columns the object declares, compared as the same type class, excluding the tenant-isolation column. The columns, the operator this filter used and the specific reason are withheld from the message (#7929); the full diagnostic is in the server log.

Why BOTH operands, not only the $field one

On a read-scope refusal the whole predicate is the administrator's. In { amount: { $gt: { $field: "secret_policy_column" } } } the target amount is as policy-authored as the referent, so echoing the target would have left half the disclosure live. The driver has no way to tell the halves apart: the scope arrives as a bare FilterCondition in where, DriverQuery is Omit< QueryAST, 'object' > with no provenance slot, and the only thing that does cross (context) says who is asking, never which subtree they did not write.

⚠️ The honest-author case is an accepted cost, stated plainly

An author debugging their own cross-field filter now gets the same redacted message. That is a real diagnostic regression, and it is what B buys until A (the spec-declared provenance mark, a separate card) restores the author-facing text behind a real mark. Their diagnostic is relocated, not destroyed: the full text is in the server log. The changeset says this in the same words, and the equality is pinned — an author-written filter and a policy-injected one must produce byte-identical messages, so "the author still sees the columns" cannot come back by accident.

The echo sites — the dispatch named three, measurement found six

The dispatch flagged its own list as a PM assumption and asked for an independent enumeration. Probing every operator with a { $field } comparand against a real driver, on origin/main:

# builder echoes in the dispatch's table?
1 crossFieldComparisonError op, target, ref, list index yes
2 bareFieldReferenceError target, ref — twice, the second inside a suggested corrected filter yes
3 uncompilableFieldReferenceError op, target, ref, reason yes
4 icontainsComparandError ($icontains) target + the comparand's JSON, i.e. the ref no
5 the $like / $ilike pattern gate target + the comparand's JSON no
6 the $null / $exists boolean gates target + the comparand's JSON no

Sites 4-6 sit on the pre-emitter reduction walk, which answers before the emitter the other cases reach — so five operators disclosed and four did not, decided by nothing a caller can see. They now route to the cross-field refusal, which is what the condition actually is. One condition, one answer, and the redacted one; no filter's accept/reject verdict changes.

The dispatch's reading of reason as carrying the disclosure (its point B) is confirmed against all four captured bodies: redacting only the {"$field": …} interpolation would have left "organization_id" is the tenant-isolation column of "cross_field_deal" live.

The server-log fork (the dispatch's point C), resolved two different ways

  • driver-sql HAS a sink: SqlDriver.logger, injected by hosts and spied by tests. The redacted refusal carries its full text on a symbol key (Symbol.for('objectstack.driver-sql.withheldFilterDiagnostic')), and the driver writes it to logger.warn. A symbol because the carrier must not travel: JSON.stringify, object spread and structured clone all skip it, so an error mapper cannot put the text back on the wire.
  • ⚠️ The seam is not where I first put it. A catch at the filter entry point missed every refusal raised inside a knex WHERE-group callback — knex invokes those lazily, at compile time, after the entry point has returned. Measured: an $or-nested refusal reached the caller correctly redacted while the log stayed silent. The catch is now at the group callbacks too, with a once-marker so one error is logged once. Re-measured: every case logs exactly one line.
  • driver-turso's RemoteTransport had NO sink — no logger, no console call, anywhere in the class. No logging subsystem was invented for it: TursoDriver extends SqlDriver, so it hands its own logger.warn down through the same setter pattern the class already uses for its client, connect factory and temporal column rule.

driver-turso is in scope, by measurement

The dispatch's point E said no copies of the message text exist in the sibling drivers, and asked me to check whether turso's remote transport has its own refusal path. It doesuncompilableComparand, its own wording, echoing 'deal.amount' $gt {"$field":"budget"}, on a path whose own doc names RLS push-down as a caller. driver-sqlite-wasm has no copy and inherits the fix (confirmed by running the shared corpus through it). Leaving remote mode disclosing would have made the exposure a property of the connection string.

Pins

Verification

pnpm test (real output in the report comment on #7929): driver-sql 1399 passed, driver-turso 947, driver-sqlite-wasm 374, service-analytics 1655, runtime 146 files / 2203 tests. #5367's end-to-end pin analytics-query-read-scope-withhold.test.ts is intact at 10/10. check:driver-conformance reads 40 covered / 0 DEBT / 0 exempt before and after. Typecheck green on all four packages that have the script; ESLint clean on every changed file.

Reverse verification, direction predicted first: restoring the operand echo in uncompilableFieldReferenceError should redden the disclosure assertions while every envelope assertion stays green. Observed exactly that — 7 of the 8 new pins red, the 8th (the author-equality pin) green as predicted, since both sides regain the names. First attempt reported a false green from a stale dist; rebuilt and re-run.

Not in this PR


Generated by Claude Code

…om INVALID_FILTER (#7929)

The refusal keeps its ADR-0112 envelope (INVALID_FILTER / 400) and refuses
exactly the same set of filters; the two column names, the operator, the list
index and the boundary reason move to the driver's server-side log.

An administrator's CEL rule compiles to `{ $field: path }` and is ANDed into the
caller's query by the security middleware or the analytics read-scope merge,
with nothing marking which subtree the caller wrote — so the old message handed
a tenant policy column names, including which column is the tenant-isolation
column of the object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoxQqG5FiUHZKCST7KDoZC
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 12, 2026 8:26pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-sql, @objectstack/driver-turso)
  • content/docs/deployment/cli.mdx (via @objectstack/driver-turso)
  • content/docs/deployment/environment-variables.mdx (via @objectstack/driver-turso)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/driver-turso)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-sql, @objectstack/driver-turso)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/anatomy.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/packages.mdx (via @objectstack/driver-sql, @objectstack/driver-turso)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-sql)

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

  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-sql)

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.

Copy link
Copy Markdown
Contributor Author

CI triage — Check Documentation Links red on 86fd6de: infrastructure, not this diff. Re-run once, no code touched.

Signature. The job died in lychee-setup, before any link was examined:

Downloading from: https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-unknown-linux-gnu.tar.gz
##[error]Process completed with exit code 22.

Exit 22 is curl's "HTTP error returned by server" — the release tarball fetch failed. Both later steps (Install lychee, Run Lychee) are skipped, so zero links were checked; the red says nothing about this PR's content, and this diff touches no docs at all.

Checked whether it is systemic before spending a re-run — it is not. Recent check-links.yml runs:

created conclusion branch
19:53:08Z failure claude/issue-7929-withhold-field-operands ← this PR
19:50:40Z success claude/issue-8019-…
19:24:01Z success claude/issue-8080-…
19:23:32Z success claude/issue-8104-…
19:13:12Z success claude/issue-8060-…

A success 2.5 minutes earlier and three more around it ⇒ a transient upstream fetch failure on one runner, not a broken pin or a repo-wide outage. Disposition: re-run failed jobs once (run 31634860387, queued). ⛔ Not repeated — if the same signature returns on the same head SHA, it stops being transient and becomes a real problem to diagnose rather than re-roll.

New signature, not the lane's known flaky (#7282, metadata-fs watcher). Recorded here rather than added to the steward's ledger — ⛔ that table is human-escalated only, and one occurrence is not a flake classification.

The other two bot comments need no action. Vercel's is a skipped deployment. The Docs Drift Check is advisory only and correctly identifies that content/docs/releases/implementation-status.mdx is release-owned and read-only — ⛔ this PR does not touch content/docs/ at all, which is the required posture, not an omission to fix.

Nothing was pushed to this branch. The dispatched dev still owns this worktree and has not yet filed its report; a second writer on the same branch is the failure mode that discipline exists to prevent. Review of the change itself comes after the report lands.


Generated by Claude Code

…ckages

driver-sql on two grounds: a new public export (withheldFilterDiagnosticOf),
and a caller-visible message change for every caller of the cross-field
refusal. driver-turso on the second ground alone — it gains no export.

The repo ships this shape as minor: #4436's envelope change on this same
seam (v17-rest-envelope-defects.md) and the connect-timeout message rewrite
(sql-driver-dialect-connect-timeout.md) are both minor, while the patch-class
neighbours refuse input the protocol never declared rather than removing
information a caller was entitled to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoxQqG5FiUHZKCST7KDoZC
@os-zhuang
os-zhuang marked this pull request as ready for review August 12, 2026 20:47
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit a5dcb74 Aug 12, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7929-withhold-field-operands branch August 12, 2026 21:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment