You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(driver-sql,driver-turso): a cross-field $field refusal stops naming the two columns it compared (#7929, #7988) (#8198)
* fix(driver-sql,driver-turso): withhold cross-field $field operands from 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
* chore(changeset): raise the #7929 withhold to minor on both driver packages
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
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
note: 'Maintainer ruling (2026-08-06) point 1 = A: no JOIN planning (disproportionate) and no alias-qualified columns (no alias contract). The memory evaluator DOES walk the path, so this is a deliberate, loudly-reported asymmetry rather than a silent one.',
363
375
},
364
376
{
365
377
name: 'a dotted path is refused even when its head names a real column',
note: 'The refusal is on the SHAPE, not on whether the first segment happens to resolve — otherwise the check would depend on data the compiler cannot see.',
note: 'The `$field` value lands in a SQL IDENTIFIER position. cloud#1051: letting it through unchecked is dismantling the guard rail — and a compile-time refusal is what makes AI-authored metadata wrong at authoring time rather than in the database.',
377
389
},
378
390
{
379
391
name: 'an undeclared TARGET field is refused too',
note: 'A comparison is one surface — validating only the referent would leave half of it unchecked, and the type-class rule below needs both declarations anyway.',
383
395
},
384
396
385
397
// ── Ruling 2, the security half: the tenant-isolation column ─────────────
386
398
{
387
399
name: 'the tenant-isolation column is refused as the REFERENT',
note: 'The named ruling. A comparison against the isolation column is a privilege-escalation comparison surface: it lets a filter probe the tenant boundary the driver injects rather than being scoped by it.',
391
403
},
392
404
{
393
405
name: 'the tenant-isolation column is refused as the TARGET',
note: 'Closed because the operands of `=` COMMUTE — a ban that a swap of the two sides walks around is not a ban. Ruling names the referent; this is the same surface spelled backwards.',
397
409
},
398
410
399
411
// ── The conformance boundary: comparison CLASS ───────────────────────────
400
412
{
401
413
name: 'a TEXT column compared to a numeric column is refused (the measured divergence)',
402
414
filter: {stage: {$gt: {$field: 'amount'}}},
403
-
messageIncludes: ['stored as'],
415
+
diagnosticIncludes: ['stored as'],
404
416
note: 'THE case that proves the class check is load-bearing, and it is directional. Measured with the check disabled: SQLite answers rows 1,2,3,5 — it orders by STORAGE CLASS first, so every TEXT sorts above every INTEGER — while the in-memory evaluator answers NONE, because JS coerces `"won" > 10` to a NaN comparison. Four rows of difference on one filter.',
405
417
},
406
418
{
407
419
name: 'a numeric column compared to a text column is refused (the mirrored spelling)',
408
420
filter: {amount: {$gt: {$field: 'stage'}}},
409
-
messageIncludes: ['stored as'],
421
+
diagnosticIncludes: ['stored as'],
410
422
note: 'The mirror of the case above, and measured to AGREE (both answer nothing) — kept in the refusal arm anyway, because a guard that admitted exactly the pairings one fixture measured as agreeing would be a rule about this data rather than about the types.',
411
423
},
412
424
{
413
425
name: 'a date column compared to a text column is refused',
414
426
filter: {starts_on: {$gt: {$field: 'stage'}}},
415
-
messageIncludes: ['stored as'],
427
+
diagnosticIncludes: ['stored as'],
416
428
note: 'Both are TEXT physically, so this one WOULD have compiled — and measured, both paths agree. It is refused because they agree by lexicographic accident rather than by any temporal reading, which is also why the class check reads declared TYPES rather than physical affinity.',
417
429
},
418
430
{
419
431
name: 'a numeric column compared to a date column is refused',
420
432
filter: {amount: {$gt: {$field: 'starts_on'}}},
421
-
messageIncludes: ['stored as'],
433
+
diagnosticIncludes: ['stored as'],
422
434
},
423
435
424
436
// ── Columns with no scalar stored form ──────────────────────────────────
425
437
{
426
438
name: 'a multi-valued (JSON) column is refused as the referent',
427
439
filter: {amount: {$gt: {$field: 'tags'}}},
428
-
messageIncludes: ['no scalar stored'],
440
+
diagnosticIncludes: ['no scalar stored'],
429
441
note: 'A JSON column holds a serialized array; SQL comparison operators have no element-wise reading of it, and #7398 already refuses the scalar operators on such a column for a value comparand.',
430
442
},
431
443
{
432
444
name: 'a formula (virtual) column is refused as the referent',
note: 'A formula field is virtual — `createColumn` emits no column at all, so there is nothing to reference. Declared-only enumeration alone would have ADMITTED it, which is why the class check is a second gate rather than a restatement of the first.',
name: 'a $field member of an $in list is refused',
460
472
filter: {amount: {$in: [{$field: 'budget'},1]}},
461
-
messageIncludes: ['index 0'],
473
+
diagnosticIncludes: ['index 0'],
462
474
note: 'Before #5041 this did not even crash: it compiled, ran, and returned ZERO ROWS. The index is named because it is the only thing distinguishing the bad member from its legitimate neighbours.',
463
475
},
464
476
{
465
477
name: 'a $field member of a $nin list is refused',
466
478
filter: {amount: {$nin: [{$field: 'budget'}]}},
467
-
messageIncludes: ['index 0'],
479
+
diagnosticIncludes: ['index 0'],
468
480
note: 'The $nin direction is the dangerous one — a lost member drops an EXCLUSION the caller wrote, widening the result set.',
469
481
},
470
482
{
471
483
name: 'a $field lower bound of a $between is refused',
0 commit comments