fix(driver-sql,driver-turso): a cross-field $field refusal stops naming the two columns it compared (#7929, #7988) - #8198
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
|
CI triage — Signature. The job died in Exit 22 is curl's "HTTP error returned by server" — the release tarball fetch failed. Both later steps ( Checked whether it is systemic before spending a re-run — it is not. Recent
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 New signature, not the lane's known flaky ( 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 ⛔ 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
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'sINVALID_FILTERrefusal stops echoing$fieldoperands 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_FILTERandstatus: 400are 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:
Why BOTH operands, not only the
$fieldoneOn a read-scope refusal the whole predicate is the administrator's. In
{ amount: { $gt: { $field: "secret_policy_column" } } }the targetamountis 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 bareFilterConditioninwhere,DriverQueryisOmit< 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.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, onorigin/main:crossFieldComparisonErrorop, target, ref, list indexbareFieldReferenceErroruncompilableFieldReferenceErrorop, target, ref,reasonicontainsComparandError($icontains)$like/$ilikepattern gate$null/$existsboolean gatesSites 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
reasonas 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-sqlHAS 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 tologger.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.$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'sRemoteTransporthad NO sink — no logger, noconsolecall, anywhere in the class. No logging subsystem was invented for it:TursoDriver extends SqlDriver, so it hands its ownlogger.warndown 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 does —
uncompilableComparand, its own wording, echoing'deal.amount' $gt {"$field":"budget"}, on a path whose own doc names RLS push-down as a caller.driver-sqlite-wasmhas 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
packages/runtime/src/cross-field-refusal-operand-withhold.test.ts(new) — (a) the four captured response bodies from 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, driven through the realPOST /api/v1/analytics/queryroute with a realAnalyticsServiceand a realSqliteWasmDriver, withexecuteRawSqlcall count 0 proving the [spec]service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598 decline actually happened; (b) finding: the #7929 read-scope disclosure is NOT analytics-only — an RLS$fieldfilter injected by the security middleware discloses the same policy column on the ordinary CRUD path, and predates #7598 #7988's CRUD measurement — a realObjectQLengine with a security-middleware-shaped$andinjection; (c) the author case, as a byte-equality between the policy-injected and author-written messages.driver-sql,driver-sqlite-wasm) now assert both halves per corpus case: the caller message names no operand, and the server log still carries the fragment that says which ruling bit. The shared corpus field was renamedmessageIncludes→diagnosticIncludes, since that is now what it describes.driver-turso's two disclosure fixtures were inverted rather than deleted — one of them pinned the echoed operands exactly — plus a new pin that serialising the error cannot recover the withheld text.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 pinanalytics-query-read-scope-withhold.test.tsis intact at 10/10.check:driver-conformancereads 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
uncompilableFieldReferenceErrorshould 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 staledist; rebuilt and re-run.Not in this PR
packages/spec,QueryASTorDriverOptions.read-scope-sqlis untouched. Measured incidentally: a list-position reference inside a read scope never reaches the driver — it is refused asREAD_SCOPE_COMPILE_FAILEDand the boundary withholds it, exactly as adjudicated.Generated by Claude Code