Skip to content

Commit a5dcb74

Browse files
os-zhuangclaude
andauthored
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>
1 parent 8eb5d8b commit a5dcb74

13 files changed

Lines changed: 1016 additions & 77 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
"@objectstack/driver-turso": minor
4+
---
5+
6+
fix(driver-sql,driver-turso): a cross-field `$field` refusal stops naming the two columns it compared (#7929, #7988)
7+
8+
`INVALID_FILTER` / 400 is unchanged, and every filter that was refused is still
9+
refused. What the caller no longer receives is the **predicate**: the referenced
10+
column, the target column, the operator, the list index, and the boundary reason.
11+
The full diagnostic now goes to the driver's server-side log instead
12+
(`SqlDriver.logger`, the sink a host already injects; `TursoDriver` hands the
13+
same sink to its remote transport).
14+
15+
**Why.** An administrator's CEL sharing/permission rule compiles to
16+
`{ $field: path }` and is ANDed into the caller's query by the security
17+
middleware (ordinary CRUD reads) or by the analytics read-scope merge. The driver
18+
receives one `FilterCondition` with nothing marking which subtree the caller
19+
wrote, so when the reference failed one of the four cross-field rulings the
20+
refusal handed a tenant an administrator's policy — measured end to end: the
21+
referenced column, the column it was compared against, and, on the tenant arm,
22+
a sentence naming **which column is the tenant-isolation column** of the object.
23+
A dotted reference came back as `sharing_rule.manager_budget`, verbatim, inside
24+
`error.message`.
25+
26+
**⚠️ This is a real diagnostic regression for authors, and it is deliberate.**
27+
An author debugging their **own** cross-field filter now gets the same redacted
28+
message — nothing in the query tells the driver whether the reference was theirs
29+
or a policy's, so the withhold cannot be conditional without inventing a guess.
30+
Their message is not destroyed, it is relocated: the full text, naming both
31+
columns, is in the server log for whoever operates the deployment. A follow-up
32+
card restores the author-facing text behind a spec-declared provenance mark set
33+
at both merge boundaries; until it lands, an author debugging a cross-field
34+
filter needs the server log or a `matchesFilter` run in memory.
35+
36+
What a caller still gets: the same `code` and `status`, which of the three
37+
cross-field refusal classes fired, and the capability statement (same-table
38+
declared columns, same type class, tenant-isolation column excluded) — none of
39+
which is derived from the filter that was sent.
40+
41+
Scope note: five operators used to answer a `{ $field }` comparand with their own
42+
comparand-shape refusal (`$icontains`, `$like`/`$ilike`, `$null`, `$exists`),
43+
each rendering the reference into its message, while the same reference at
44+
`$contains` was answered by the cross-field refusal. They now all answer with the
45+
cross-field refusal — one condition, one answer, and the redacted one.

packages/drivers/driver-sql/src/cross-field-conformance-cases.ts

Lines changed: 69 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,17 @@ export const CROSS_FIELD_AUTHORED_CASES: readonly CrossFieldAuthoredCase[] = [
331331
* SECURITY surface rather than a capability one.
332332
*
333333
* Every entry must throw `INVALID_FILTER` / 400 (ADR-0112) on BOTH SQL
334-
* drivers. `messageIncludes` pins the part of the wording a caller needs to
335-
* act on; the tests assert the envelope regardless.
334+
* drivers. `diagnosticIncludes` pins the wording that says WHICH ruling bit;
335+
* the tests assert the envelope regardless.
336+
*
337+
* [#7929, maintainer ruling 2026-08-12] That wording moved. It used to be
338+
* `messageIncludes` — substrings of the message the CALLER receives — and the
339+
* rename is the change, not a tidy-up: those sentences name the two columns of
340+
* the comparison, and on a read-scope refusal both were written by an
341+
* administrator whose policy the tenant never saw. The refusal now answers the
342+
* caller with an operand-free sentence and puts this text in the server log, so
343+
* these fragments are asserted against the LOGGED diagnostic. A test that finds
344+
* one of them in `error.message` is finding the disclosure this card closed.
336345
*
337346
* Read this table together with {@link CROSS_FIELD_CASES}: what makes the
338347
* refusals defensible is that the supported arm above is proven equivalent, so
@@ -342,8 +351,11 @@ export const CROSS_FIELD_AUTHORED_CASES: readonly CrossFieldAuthoredCase[] = [
342351
export interface CrossFieldRefusalCase {
343352
name: string;
344353
filter: unknown;
345-
/** Substrings the refusal message must contain. */
346-
messageIncludes: string[];
354+
/**
355+
* Substrings the SERVER-LOG diagnostic must contain (#7929) — never the
356+
* caller-visible message, which names no operand at all.
357+
*/
358+
diagnosticIncludes: string[];
347359
/**
348360
* Why the shape is refused — surfaced in failure output. Optional because
349361
* several entries are one spelling of a reason the entry above them states
@@ -358,80 +370,80 @@ export const CROSS_FIELD_REFUSALS: readonly CrossFieldRefusalCase[] = [
358370
{
359371
name: 'a dotted relation path is refused',
360372
filter: { amount: { $gt: { $field: 'account.budget' } } },
361-
messageIncludes: ['dotted path', 'same-table'],
373+
diagnosticIncludes: ['dotted path', 'same-table'],
362374
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.',
363375
},
364376
{
365377
name: 'a dotted path is refused even when its head names a real column',
366378
filter: { amount: { $gt: { $field: 'budget.nested' } } },
367-
messageIncludes: ['dotted path'],
379+
diagnosticIncludes: ['dotted path'],
368380
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.',
369381
},
370382

371383
// ── Ruling 2: declared-only enumeration ──────────────────────────────────
372384
{
373385
name: 'an undeclared column is refused at compile time',
374386
filter: { amount: { $gt: { $field: 'no_such_column' } } },
375-
messageIncludes: ['not a declared field'],
387+
diagnosticIncludes: ['not a declared field'],
376388
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.',
377389
},
378390
{
379391
name: 'an undeclared TARGET field is refused too',
380392
filter: { no_such_column: { $gt: { $field: 'budget' } } },
381-
messageIncludes: ['not a declared field'],
393+
diagnosticIncludes: ['not a declared field'],
382394
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.',
383395
},
384396

385397
// ── Ruling 2, the security half: the tenant-isolation column ─────────────
386398
{
387399
name: 'the tenant-isolation column is refused as the REFERENT',
388400
filter: { stage: { $eq: { $field: 'organization_id' } } },
389-
messageIncludes: ['tenant-isolation column'],
401+
diagnosticIncludes: ['tenant-isolation column'],
390402
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.',
391403
},
392404
{
393405
name: 'the tenant-isolation column is refused as the TARGET',
394406
filter: { organization_id: { $eq: { $field: 'stage' } } },
395-
messageIncludes: ['tenant-isolation column'],
407+
diagnosticIncludes: ['tenant-isolation column'],
396408
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.',
397409
},
398410

399411
// ── The conformance boundary: comparison CLASS ───────────────────────────
400412
{
401413
name: 'a TEXT column compared to a numeric column is refused (the measured divergence)',
402414
filter: { stage: { $gt: { $field: 'amount' } } },
403-
messageIncludes: ['stored as'],
415+
diagnosticIncludes: ['stored as'],
404416
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.',
405417
},
406418
{
407419
name: 'a numeric column compared to a text column is refused (the mirrored spelling)',
408420
filter: { amount: { $gt: { $field: 'stage' } } },
409-
messageIncludes: ['stored as'],
421+
diagnosticIncludes: ['stored as'],
410422
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.',
411423
},
412424
{
413425
name: 'a date column compared to a text column is refused',
414426
filter: { starts_on: { $gt: { $field: 'stage' } } },
415-
messageIncludes: ['stored as'],
427+
diagnosticIncludes: ['stored as'],
416428
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.',
417429
},
418430
{
419431
name: 'a numeric column compared to a date column is refused',
420432
filter: { amount: { $gt: { $field: 'starts_on' } } },
421-
messageIncludes: ['stored as'],
433+
diagnosticIncludes: ['stored as'],
422434
},
423435

424436
// ── Columns with no scalar stored form ──────────────────────────────────
425437
{
426438
name: 'a multi-valued (JSON) column is refused as the referent',
427439
filter: { amount: { $gt: { $field: 'tags' } } },
428-
messageIncludes: ['no scalar stored'],
440+
diagnosticIncludes: ['no scalar stored'],
429441
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.',
430442
},
431443
{
432444
name: 'a formula (virtual) column is refused as the referent',
433445
filter: { amount: { $gt: { $field: 'projected_total' } } },
434-
messageIncludes: ['no scalar stored'],
446+
diagnosticIncludes: ['no scalar stored'],
435447
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.',
436448
},
437449

@@ -458,24 +470,24 @@ export const CROSS_FIELD_REFUSALS: readonly CrossFieldRefusalCase[] = [
458470
{
459471
name: 'a $field member of an $in list is refused',
460472
filter: { amount: { $in: [{ $field: 'budget' }, 1] } },
461-
messageIncludes: ['index 0'],
473+
diagnosticIncludes: ['index 0'],
462474
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.',
463475
},
464476
{
465477
name: 'a $field member of a $nin list is refused',
466478
filter: { amount: { $nin: [{ $field: 'budget' }] } },
467-
messageIncludes: ['index 0'],
479+
diagnosticIncludes: ['index 0'],
468480
note: 'The $nin direction is the dangerous one — a lost member drops an EXCLUSION the caller wrote, widening the result set.',
469481
},
470482
{
471483
name: 'a $field lower bound of a $between is refused',
472484
filter: { amount: { $between: [{ $field: 'budget' }, 100] } },
473-
messageIncludes: ['index 0'],
485+
diagnosticIncludes: ['index 0'],
474486
},
475487
{
476488
name: 'a $field upper bound of a $between is refused',
477489
filter: { amount: { $between: [0, { $field: 'budget' }] } },
478-
messageIncludes: ['index 1'],
490+
diagnosticIncludes: ['index 1'],
479491
},
480492

481493
// ── String operators — refused in v1, and the reason is a filter bypass ──
@@ -489,27 +501,59 @@ export const CROSS_FIELD_REFUSALS: readonly CrossFieldRefusalCase[] = [
489501
{
490502
name: '$startsWith against a field reference is refused',
491503
filter: { stage: { $startsWith: { $field: 'owner' } } },
492-
messageIncludes: ['$field'],
504+
diagnosticIncludes: ['$field'],
493505
note: 'v1 refusal: a column-side LIKE pattern cannot be metacharacter-escaped portably, and an unescaped one is the `%`-matches-every-row bypass.',
494506
},
495507
{
496508
name: '$contains against a field reference is refused',
497509
filter: { stage: { $contains: { $field: 'owner' } } },
498-
messageIncludes: ['$field'],
510+
diagnosticIncludes: ['$field'],
499511
},
500512
{
501513
name: '$endsWith against a field reference is refused',
502514
filter: { stage: { $endsWith: { $field: 'owner' } } },
503-
messageIncludes: ['$field'],
515+
diagnosticIncludes: ['$field'],
504516
},
505517
{
506518
name: '$notContains against a field reference is refused',
507519
filter: { stage: { $notContains: { $field: 'owner' } } },
508-
messageIncludes: ['$field'],
520+
diagnosticIncludes: ['$field'],
509521
},
510522
{
511523
name: '$icontains against a field reference is refused',
512524
filter: { stage: { $icontains: { $field: 'owner' } } },
513-
messageIncludes: ['$field'],
525+
diagnosticIncludes: ['$field'],
514526
},
515527
] as const;
528+
529+
/**
530+
* [#7929] Every column name a {@link CROSS_FIELD_REFUSALS} entry can put in its
531+
* operands — the list a CALLER-VISIBLE refusal message must contain none of.
532+
*
533+
* The corpus's own filters are the source: the declared columns of
534+
* {@link CROSS_FIELD_OBJECT_FIELDS} that appear on either side of a refused
535+
* comparison, plus the three names that are refused precisely because the
536+
* object does NOT declare them. Both sides matter — on a read-scope refusal the
537+
* administrator wrote the target column as surely as the referenced one, so a
538+
* check that watched only the `$field` value would pass a message still naming
539+
* half the policy.
540+
*
541+
* `id` is deliberately absent. No refusal case references it, and a two-letter
542+
* substring search over English prose reports a disclosure for words like
543+
* "considered" — an assertion that fails for reasons unrelated to what it
544+
* claims is worse than no assertion. Add a name here when a case adds one.
545+
*/
546+
export const CROSS_FIELD_OPERAND_NAMES: readonly string[] = [
547+
'amount',
548+
'budget',
549+
'stage',
550+
'owner',
551+
'starts_on',
552+
'ends_on',
553+
'organization_id',
554+
'tags',
555+
'projected_total',
556+
'account.budget',
557+
'budget.nested',
558+
'no_such_column',
559+
];

packages/drivers/driver-sql/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ export { SqlDriver };
77
// service layer's native→wasm step-down resolves the same answer for its
88
// fallback rung — one judgement, two call sites, no second `existsSync`.
99
export { resolveSqliteAbsentFileTarget } from './sql-driver.js';
10+
// [#7929] The read half of the cross-field refusal's withhold: the full,
11+
// operand-naming diagnostic a redacted `INVALID_FILTER` carries under a symbol
12+
// key, for a host that maps driver errors itself and wants the same text in its
13+
// own log. `SqlDriver` writes it to `this.logger` already — this export is what
14+
// stops an embedder from re-deriving the seam (or, worse, putting the text back
15+
// on the wire by spreading the error, which the symbol key exists to prevent).
16+
export { withheldFilterDiagnosticOf } from './sql-driver.js';
1017
export type {
1118
SqlDriverConfig,
1219
SqliteJournalMode,
@@ -40,6 +47,7 @@ export {
4047
CROSS_FIELD_AUTHORED_CASES,
4148
CROSS_FIELD_CASES,
4249
CROSS_FIELD_OBJECT_FIELDS,
50+
CROSS_FIELD_OPERAND_NAMES,
4351
CROSS_FIELD_REFUSALS,
4452
CROSS_FIELD_ROWS,
4553
} from './cross-field-conformance-cases.js';

packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
CROSS_FIELD_AUTHORED_CASES,
6262
CROSS_FIELD_CASES,
6363
CROSS_FIELD_OBJECT_FIELDS,
64+
CROSS_FIELD_OPERAND_NAMES,
6465
CROSS_FIELD_REFUSALS,
6566
CROSS_FIELD_ROWS,
6667
} from './cross-field-conformance-cases.js';
@@ -160,12 +161,25 @@ describe(`[#5222] driver-sql — cross-field \`$field\` push-down conformance ($
160161

161162
describe('the refusal arm — narrowed, never removed (ADR-0112 envelope)', () => {
162163
for (const refusal of CROSS_FIELD_REFUSALS) {
163-
it(`${refusal.name} → 400 INVALID_FILTER`, async () => {
164+
it(`${refusal.name} → 400 INVALID_FILTER, operands withheld`, async () => {
165+
// [#7929] Two halves, asserted together because either one alone is
166+
// satisfiable by the wrong implementation: a refusal that says nothing
167+
// at all passes the disclosure half, and the pre-#7929 message passes
168+
// the diagnostic half. The pair is the deliverable — "still refused,
169+
// same code, same status" AND "no longer discloses".
170+
const logged: string[] = [];
171+
const restore = (driver as unknown as { logger: { warn: (m: string) => void } }).logger;
172+
(driver as unknown as { logger: unknown }).logger = {
173+
...restore,
174+
warn: (m: string) => { logged.push(m); },
175+
};
164176
let error: (Error & { code?: string; status?: number }) | null = null;
165177
try {
166178
await sqlIds(refusal.filter);
167179
} catch (e) {
168180
error = e as Error & { code?: string; status?: number };
181+
} finally {
182+
(driver as unknown as { logger: unknown }).logger = restore;
169183
}
170184
expect(error, `expected a refusal${refusal.note ? `\n${refusal.note}` : ""}`).not.toBeNull();
171185
expect(error!.code).toBe('INVALID_FILTER');
@@ -175,8 +189,12 @@ describe(`[#5222] driver-sql — cross-field \`$field\` push-down conformance ($
175189
expect(error!).not.toBeInstanceOf(TypeError);
176190
expect(error!.message).not.toContain('can only bind');
177191
expect(error!.message).not.toContain('[sql-driver]');
178-
for (const fragment of refusal.messageIncludes) {
179-
expect(error!.message).toContain(fragment);
192+
for (const name of CROSS_FIELD_OPERAND_NAMES) {
193+
expect(error!.message, `caller-visible message names "${name}"`).not.toContain(name);
194+
}
195+
const diagnostic = logged.join('\n');
196+
for (const fragment of refusal.diagnosticIncludes) {
197+
expect(diagnostic, `server log lost "${fragment}"`).toContain(fragment);
180198
}
181199
});
182200
}

0 commit comments

Comments
 (0)