Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/withhold-cross-field-operands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@objectstack/driver-sql": minor
"@objectstack/driver-turso": minor
---

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

`INVALID_FILTER` / 400 is unchanged, and every filter that was refused is still
refused. What the caller no longer receives is the **predicate**: the referenced
column, the target column, the operator, the list index, and the boundary reason.
The full diagnostic now goes to the driver's server-side log instead
(`SqlDriver.logger`, the sink a host already injects; `TursoDriver` hands the
same sink to its remote transport).

**Why.** An administrator's CEL sharing/permission rule compiles to
`{ $field: path }` and is ANDed into the caller's query by the security
middleware (ordinary CRUD reads) or by the analytics read-scope merge. The driver
receives one `FilterCondition` with nothing marking which subtree the caller
wrote, so when the reference failed one of the four cross-field rulings the
refusal handed a tenant an administrator's policy — measured end to end: the
referenced column, the column it was compared against, and, on the tenant arm,
a sentence naming **which column is the tenant-isolation column** of the object.
A dotted reference came back as `sharing_rule.manager_budget`, verbatim, inside
`error.message`.

**⚠️ This is a real diagnostic regression for authors, and it is deliberate.**
An author debugging their **own** cross-field filter now gets the same redacted
message — nothing in the query tells the driver whether the reference was theirs
or a policy's, so the withhold cannot be conditional without inventing a guess.
Their message is not destroyed, it is relocated: the full text, naming both
columns, is in the server log for whoever operates the deployment. A follow-up
card restores the author-facing text behind a spec-declared provenance mark set
at both merge boundaries; until it lands, an author debugging a cross-field
filter needs the server log or a `matchesFilter` run in memory.

What a caller still gets: the same `code` and `status`, which of the three
cross-field refusal classes fired, and the capability statement (same-table
declared columns, same type class, tenant-isolation column excluded) — none of
which is derived from the filter that was sent.

Scope note: five operators used to answer a `{ $field }` comparand with their own
comparand-shape refusal (`$icontains`, `$like`/`$ilike`, `$null`, `$exists`),
each rendering the reference into its message, while the same reference at
`$contains` was answered by the cross-field refusal. They now all answer with the
cross-field refusal — one condition, one answer, and the redacted one.
94 changes: 69 additions & 25 deletions packages/drivers/driver-sql/src/cross-field-conformance-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,17 @@ export const CROSS_FIELD_AUTHORED_CASES: readonly CrossFieldAuthoredCase[] = [
* SECURITY surface rather than a capability one.
*
* Every entry must throw `INVALID_FILTER` / 400 (ADR-0112) on BOTH SQL
* drivers. `messageIncludes` pins the part of the wording a caller needs to
* act on; the tests assert the envelope regardless.
* drivers. `diagnosticIncludes` pins the wording that says WHICH ruling bit;
* the tests assert the envelope regardless.
*
* [#7929, maintainer ruling 2026-08-12] That wording moved. It used to be
* `messageIncludes` — substrings of the message the CALLER receives — and the
* rename is the change, not a tidy-up: those sentences name the two columns of
* the comparison, and on a read-scope refusal both were written by an
* administrator whose policy the tenant never saw. The refusal now answers the
* caller with an operand-free sentence and puts this text in the server log, so
* these fragments are asserted against the LOGGED diagnostic. A test that finds
* one of them in `error.message` is finding the disclosure this card closed.
*
* Read this table together with {@link CROSS_FIELD_CASES}: what makes the
* refusals defensible is that the supported arm above is proven equivalent, so
Expand All @@ -342,8 +351,11 @@ export const CROSS_FIELD_AUTHORED_CASES: readonly CrossFieldAuthoredCase[] = [
export interface CrossFieldRefusalCase {
name: string;
filter: unknown;
/** Substrings the refusal message must contain. */
messageIncludes: string[];
/**
* Substrings the SERVER-LOG diagnostic must contain (#7929) — never the
* caller-visible message, which names no operand at all.
*/
diagnosticIncludes: string[];
/**
* Why the shape is refused — surfaced in failure output. Optional because
* several entries are one spelling of a reason the entry above them states
Expand All @@ -358,80 +370,80 @@ export const CROSS_FIELD_REFUSALS: readonly CrossFieldRefusalCase[] = [
{
name: 'a dotted relation path is refused',
filter: { amount: { $gt: { $field: 'account.budget' } } },
messageIncludes: ['dotted path', 'same-table'],
diagnosticIncludes: ['dotted path', 'same-table'],
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.',
},
{
name: 'a dotted path is refused even when its head names a real column',
filter: { amount: { $gt: { $field: 'budget.nested' } } },
messageIncludes: ['dotted path'],
diagnosticIncludes: ['dotted path'],
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.',
},

// ── Ruling 2: declared-only enumeration ──────────────────────────────────
{
name: 'an undeclared column is refused at compile time',
filter: { amount: { $gt: { $field: 'no_such_column' } } },
messageIncludes: ['not a declared field'],
diagnosticIncludes: ['not a declared field'],
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.',
},
{
name: 'an undeclared TARGET field is refused too',
filter: { no_such_column: { $gt: { $field: 'budget' } } },
messageIncludes: ['not a declared field'],
diagnosticIncludes: ['not a declared field'],
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.',
},

// ── Ruling 2, the security half: the tenant-isolation column ─────────────
{
name: 'the tenant-isolation column is refused as the REFERENT',
filter: { stage: { $eq: { $field: 'organization_id' } } },
messageIncludes: ['tenant-isolation column'],
diagnosticIncludes: ['tenant-isolation column'],
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.',
},
{
name: 'the tenant-isolation column is refused as the TARGET',
filter: { organization_id: { $eq: { $field: 'stage' } } },
messageIncludes: ['tenant-isolation column'],
diagnosticIncludes: ['tenant-isolation column'],
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.',
},

// ── The conformance boundary: comparison CLASS ───────────────────────────
{
name: 'a TEXT column compared to a numeric column is refused (the measured divergence)',
filter: { stage: { $gt: { $field: 'amount' } } },
messageIncludes: ['stored as'],
diagnosticIncludes: ['stored as'],
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.',
},
{
name: 'a numeric column compared to a text column is refused (the mirrored spelling)',
filter: { amount: { $gt: { $field: 'stage' } } },
messageIncludes: ['stored as'],
diagnosticIncludes: ['stored as'],
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.',
},
{
name: 'a date column compared to a text column is refused',
filter: { starts_on: { $gt: { $field: 'stage' } } },
messageIncludes: ['stored as'],
diagnosticIncludes: ['stored as'],
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.',
},
{
name: 'a numeric column compared to a date column is refused',
filter: { amount: { $gt: { $field: 'starts_on' } } },
messageIncludes: ['stored as'],
diagnosticIncludes: ['stored as'],
},

// ── Columns with no scalar stored form ──────────────────────────────────
{
name: 'a multi-valued (JSON) column is refused as the referent',
filter: { amount: { $gt: { $field: 'tags' } } },
messageIncludes: ['no scalar stored'],
diagnosticIncludes: ['no scalar stored'],
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.',
},
{
name: 'a formula (virtual) column is refused as the referent',
filter: { amount: { $gt: { $field: 'projected_total' } } },
messageIncludes: ['no scalar stored'],
diagnosticIncludes: ['no scalar stored'],
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.',
},

Expand All @@ -458,24 +470,24 @@ export const CROSS_FIELD_REFUSALS: readonly CrossFieldRefusalCase[] = [
{
name: 'a $field member of an $in list is refused',
filter: { amount: { $in: [{ $field: 'budget' }, 1] } },
messageIncludes: ['index 0'],
diagnosticIncludes: ['index 0'],
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.',
},
{
name: 'a $field member of a $nin list is refused',
filter: { amount: { $nin: [{ $field: 'budget' }] } },
messageIncludes: ['index 0'],
diagnosticIncludes: ['index 0'],
note: 'The $nin direction is the dangerous one — a lost member drops an EXCLUSION the caller wrote, widening the result set.',
},
{
name: 'a $field lower bound of a $between is refused',
filter: { amount: { $between: [{ $field: 'budget' }, 100] } },
messageIncludes: ['index 0'],
diagnosticIncludes: ['index 0'],
},
{
name: 'a $field upper bound of a $between is refused',
filter: { amount: { $between: [0, { $field: 'budget' }] } },
messageIncludes: ['index 1'],
diagnosticIncludes: ['index 1'],
},

// ── String operators — refused in v1, and the reason is a filter bypass ──
Expand All @@ -489,27 +501,59 @@ export const CROSS_FIELD_REFUSALS: readonly CrossFieldRefusalCase[] = [
{
name: '$startsWith against a field reference is refused',
filter: { stage: { $startsWith: { $field: 'owner' } } },
messageIncludes: ['$field'],
diagnosticIncludes: ['$field'],
note: 'v1 refusal: a column-side LIKE pattern cannot be metacharacter-escaped portably, and an unescaped one is the `%`-matches-every-row bypass.',
},
{
name: '$contains against a field reference is refused',
filter: { stage: { $contains: { $field: 'owner' } } },
messageIncludes: ['$field'],
diagnosticIncludes: ['$field'],
},
{
name: '$endsWith against a field reference is refused',
filter: { stage: { $endsWith: { $field: 'owner' } } },
messageIncludes: ['$field'],
diagnosticIncludes: ['$field'],
},
{
name: '$notContains against a field reference is refused',
filter: { stage: { $notContains: { $field: 'owner' } } },
messageIncludes: ['$field'],
diagnosticIncludes: ['$field'],
},
{
name: '$icontains against a field reference is refused',
filter: { stage: { $icontains: { $field: 'owner' } } },
messageIncludes: ['$field'],
diagnosticIncludes: ['$field'],
},
] as const;

/**
* [#7929] Every column name a {@link CROSS_FIELD_REFUSALS} entry can put in its
* operands — the list a CALLER-VISIBLE refusal message must contain none of.
*
* The corpus's own filters are the source: the declared columns of
* {@link CROSS_FIELD_OBJECT_FIELDS} that appear on either side of a refused
* comparison, plus the three names that are refused precisely because the
* object does NOT declare them. Both sides matter — on a read-scope refusal the
* administrator wrote the target column as surely as the referenced one, so a
* check that watched only the `$field` value would pass a message still naming
* half the policy.
*
* `id` is deliberately absent. No refusal case references it, and a two-letter
* substring search over English prose reports a disclosure for words like
* "considered" — an assertion that fails for reasons unrelated to what it
* claims is worse than no assertion. Add a name here when a case adds one.
*/
export const CROSS_FIELD_OPERAND_NAMES: readonly string[] = [
'amount',
'budget',
'stage',
'owner',
'starts_on',
'ends_on',
'organization_id',
'tags',
'projected_total',
'account.budget',
'budget.nested',
'no_such_column',
];
8 changes: 8 additions & 0 deletions packages/drivers/driver-sql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ export { SqlDriver };
// service layer's native→wasm step-down resolves the same answer for its
// fallback rung — one judgement, two call sites, no second `existsSync`.
export { resolveSqliteAbsentFileTarget } from './sql-driver.js';
// [#7929] The read half of the cross-field refusal's withhold: the full,
// operand-naming diagnostic a redacted `INVALID_FILTER` carries under a symbol
// key, for a host that maps driver errors itself and wants the same text in its
// own log. `SqlDriver` writes it to `this.logger` already — this export is what
// stops an embedder from re-deriving the seam (or, worse, putting the text back
// on the wire by spreading the error, which the symbol key exists to prevent).
export { withheldFilterDiagnosticOf } from './sql-driver.js';
export type {
SqlDriverConfig,
SqliteJournalMode,
Expand Down Expand Up @@ -40,6 +47,7 @@ export {
CROSS_FIELD_AUTHORED_CASES,
CROSS_FIELD_CASES,
CROSS_FIELD_OBJECT_FIELDS,
CROSS_FIELD_OPERAND_NAMES,
CROSS_FIELD_REFUSALS,
CROSS_FIELD_ROWS,
} from './cross-field-conformance-cases.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
CROSS_FIELD_AUTHORED_CASES,
CROSS_FIELD_CASES,
CROSS_FIELD_OBJECT_FIELDS,
CROSS_FIELD_OPERAND_NAMES,
CROSS_FIELD_REFUSALS,
CROSS_FIELD_ROWS,
} from './cross-field-conformance-cases.js';
Expand Down Expand Up @@ -160,12 +161,25 @@ describe(`[#5222] driver-sql — cross-field \`$field\` push-down conformance ($

describe('the refusal arm — narrowed, never removed (ADR-0112 envelope)', () => {
for (const refusal of CROSS_FIELD_REFUSALS) {
it(`${refusal.name} → 400 INVALID_FILTER`, async () => {
it(`${refusal.name} → 400 INVALID_FILTER, operands withheld`, async () => {
// [#7929] Two halves, asserted together because either one alone is
// satisfiable by the wrong implementation: a refusal that says nothing
// at all passes the disclosure half, and the pre-#7929 message passes
// the diagnostic half. The pair is the deliverable — "still refused,
// same code, same status" AND "no longer discloses".
const logged: string[] = [];
const restore = (driver as unknown as { logger: { warn: (m: string) => void } }).logger;
(driver as unknown as { logger: unknown }).logger = {
...restore,
warn: (m: string) => { logged.push(m); },
};
let error: (Error & { code?: string; status?: number }) | null = null;
try {
await sqlIds(refusal.filter);
} catch (e) {
error = e as Error & { code?: string; status?: number };
} finally {
(driver as unknown as { logger: unknown }).logger = restore;
}
expect(error, `expected a refusal${refusal.note ? `\n${refusal.note}` : ""}`).not.toBeNull();
expect(error!.code).toBe('INVALID_FILTER');
Expand All @@ -175,8 +189,12 @@ describe(`[#5222] driver-sql — cross-field \`$field\` push-down conformance ($
expect(error!).not.toBeInstanceOf(TypeError);
expect(error!.message).not.toContain('can only bind');
expect(error!.message).not.toContain('[sql-driver]');
for (const fragment of refusal.messageIncludes) {
expect(error!.message).toContain(fragment);
for (const name of CROSS_FIELD_OPERAND_NAMES) {
expect(error!.message, `caller-visible message names "${name}"`).not.toContain(name);
}
const diagnostic = logged.join('\n');
for (const fragment of refusal.diagnosticIncludes) {
expect(diagnostic, `server log lost "${fragment}"`).toContain(fragment);
}
});
}
Expand Down
Loading
Loading