From 86fd6de337d289b5492eacfbe1a860679ec3ec9b Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 19:38:25 +0000 Subject: [PATCH 1/2] fix(driver-sql,driver-turso): withhold cross-field $field operands from INVALID_FILTER (#7929) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VoxQqG5FiUHZKCST7KDoZC --- .changeset/withhold-cross-field-operands.md | 45 ++ .../src/cross-field-conformance-cases.ts | 94 +++-- packages/drivers/driver-sql/src/index.ts | 8 + ...sql-driver-cross-field-conformance.test.ts | 24 +- .../sql-driver-cross-field-reference.test.ts | 63 ++- .../sql-driver-silent-empty-predicate.test.ts | 20 +- packages/drivers/driver-sql/src/sql-driver.ts | 254 ++++++++++- ...qlite-wasm-cross-field-conformance.test.ts | 27 +- ...remote-transport-comparand-refusal.test.ts | 52 ++- .../driver-turso/src/remote-transport.ts | 79 +++- .../drivers/driver-turso/src/turso-driver.ts | 10 + ...oss-field-refusal-operand-withhold.test.ts | 393 ++++++++++++++++++ .../cross-field-engine-fallback.test.ts | 24 +- 13 files changed, 1016 insertions(+), 77 deletions(-) create mode 100644 .changeset/withhold-cross-field-operands.md create mode 100644 packages/runtime/src/cross-field-refusal-operand-withhold.test.ts diff --git a/.changeset/withhold-cross-field-operands.md b/.changeset/withhold-cross-field-operands.md new file mode 100644 index 0000000000..d8fae8fa80 --- /dev/null +++ b/.changeset/withhold-cross-field-operands.md @@ -0,0 +1,45 @@ +--- +"@objectstack/driver-sql": patch +"@objectstack/driver-turso": patch +--- + +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. diff --git a/packages/drivers/driver-sql/src/cross-field-conformance-cases.ts b/packages/drivers/driver-sql/src/cross-field-conformance-cases.ts index 9b2e647b0d..81fb944d69 100644 --- a/packages/drivers/driver-sql/src/cross-field-conformance-cases.ts +++ b/packages/drivers/driver-sql/src/cross-field-conformance-cases.ts @@ -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 @@ -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 @@ -358,13 +370,13 @@ 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.', }, @@ -372,13 +384,13 @@ export const CROSS_FIELD_REFUSALS: readonly CrossFieldRefusalCase[] = [ { 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.', }, @@ -386,13 +398,13 @@ export const CROSS_FIELD_REFUSALS: readonly CrossFieldRefusalCase[] = [ { 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.', }, @@ -400,38 +412,38 @@ export const CROSS_FIELD_REFUSALS: readonly CrossFieldRefusalCase[] = [ { 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.', }, @@ -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 ── @@ -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', +]; diff --git a/packages/drivers/driver-sql/src/index.ts b/packages/drivers/driver-sql/src/index.ts index b182cc0027..99d71b7231 100644 --- a/packages/drivers/driver-sql/src/index.ts +++ b/packages/drivers/driver-sql/src/index.ts @@ -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, @@ -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'; diff --git a/packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts index b6ed4473fb..541373b4c4 100644 --- a/packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts @@ -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'; @@ -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'); @@ -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); } }); } diff --git a/packages/drivers/driver-sql/src/sql-driver-cross-field-reference.test.ts b/packages/drivers/driver-sql/src/sql-driver-cross-field-reference.test.ts index b2f01eea42..54451dbc4b 100644 --- a/packages/drivers/driver-sql/src/sql-driver-cross-field-reference.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-cross-field-reference.test.ts @@ -88,6 +88,33 @@ describe('[#5222] SqlDriver `$field` position matrix — compiled vs refused', ( driver.find('deal', { fields: ['id'], where: where as FilterCondition }); const ids = async (where: unknown) => (await find(where)).map((r: any) => String(r.id)); + /** + * [#7929] Run a refusal and return BOTH halves: the error the caller sees and + * everything the driver wrote to its log while raising it. + * + * The two are asserted together throughout the refusal block below, because + * each is satisfiable alone by an implementation nobody wants — a refusal + * that says nothing at all passes the disclosure half, and the pre-#7929 + * message passes the reason half. The seam under test is `SqlDriver`'s own + * `logger` property, spied here the way a host injects a real sink. + */ + const refusalWithLog = async ( + run: () => Promise, + ): Promise<{ err: WireBearingError; logged: string }> => { + const lines: string[] = []; + const restore = (driver as unknown as { logger: Record }).logger; + (driver as unknown as { logger: unknown }).logger = { + ...restore, + warn: (m: string) => { lines.push(m); }, + }; + try { + const err = await refusalOf(run); + return { err, logged: lines.join('\n') }; + } finally { + (driver as unknown as { logger: unknown }).logger = restore; + } + }; + // ── SUPPORTED: the six scalar comparison operators ──────────────────────── // // The issue's repro is the first row. Each expectation is decided by the one @@ -185,6 +212,13 @@ describe('[#5222] SqlDriver `$field` position matrix — compiled vs refused', ( // list against both SQL drivers; this block pins the ones whose WORDING a // caller has to act on, plus the positions unique to this matrix. describe('refused positions keep the INVALID_FILTER envelope', () => { + // [#7929] The third column moved from "a fragment of the CALLER'S message" + // to "a fragment of the SERVER LOG". The refusals are unchanged — same + // code, same status, same set of refused shapes — but the sentence that + // says WHICH boundary bit no longer travels to the caller, because on a + // read-scope refusal every name in it was written by an administrator the + // caller never saw. `refusalWithLog` captures the log line beside the error so + // both halves are asserted on one run. const refused: Array<[string, unknown, string]> = [ ['a dotted relation path', { amount: { $gt: { $field: 'a.b' } } }, 'dotted path'], ['an undeclared column', { amount: { $gt: { $field: 'nope' } } }, 'not a declared field'], @@ -203,14 +237,17 @@ describe('[#5222] SqlDriver `$field` position matrix — compiled vs refused', ( ]; for (const [name, where, fragment] of refused) { - it(`${name} → 400 INVALID_FILTER naming the reason`, async () => { - const err = await refusalOf(() => find(where)); + it(`${name} → 400 INVALID_FILTER, reason in the log and not on the wire`, async () => { + const { err, logged } = await refusalWithLog(() => find(where)); expect(err.code).toBe('INVALID_FILTER'); expect(err.status).toBe(400); expect(err).not.toBeInstanceOf(TypeError); expect(err.message).not.toContain('can only bind'); expect(err.message).not.toContain('[sql-driver]'); - expect(err.message).toContain(fragment); + expect(logged, 'the reason must survive, server-side').toContain(fragment); + for (const column of ['amount', 'budget', 'stage', 'note', 'organization_id', 'nope', 'a.b']) { + expect(err.message, `caller-visible message names "${column}"`).not.toContain(column); + } }); } @@ -219,11 +256,16 @@ describe('[#5222] SqlDriver `$field` position matrix — compiled vs refused', ( // recurses into — so nesting is not a way around it. Worth pinning // because a validation placed at the top-level entry instead would pass // this and refuse only the flat spelling. - const err = await refusalOf(() => + // + // [#7929] And the withhold recurses with it: the log seam sits at + // `applyFilters`, one frame OUTSIDE the recursion, so a refusal raised + // three combinators deep still reaches it exactly once. + const { err, logged } = await refusalWithLog(() => find({ $or: [{ stage: 'won' }, { amount: { $gt: { $field: 'organization_id' } } }] }), ); expect(err.code).toBe('INVALID_FILTER'); - expect(err.message).toContain('tenant-isolation column'); + expect(logged).toContain('tenant-isolation column'); + expect(err.message).not.toContain('organization_id'); }); it('the bare `{ field: { $field } }` spelling names the operator form to use', async () => { @@ -240,11 +282,18 @@ describe('[#5222] SqlDriver `$field` position matrix — compiled vs refused', ( // unknown-operator posture, deliberately kept), so compiling it would // open a divergence — and the message points at the `$eq` spelling that // does compile. - const err = await refusalOf(() => find({ amount: { $field: 'budget' } })); + // + // [#7929] The prescription survives as a SHAPE with placeholder names — + // the old text spelled it out with the caller's two real columns, which + // on a read-scope refusal is the administrator's policy handed back as a + // suggestion. The real names are in the log line instead. + const { err, logged } = await refusalWithLog(() => find({ amount: { $field: 'budget' } })); expect(err.code).toBe('INVALID_FILTER'); expect(err.status).toBe(400); expect(err.message).toContain('$eq'); - expect(err.message).toContain('budget'); + expect(err.message).not.toContain('budget'); + expect(err.message).not.toContain('amount'); + expect(logged).toContain('"amount": { "$eq": { "$field": "budget" } }'); }); it('the equality TRIPLE no longer lowers to that bare spelling (#7597)', async () => { diff --git a/packages/drivers/driver-sql/src/sql-driver-silent-empty-predicate.test.ts b/packages/drivers/driver-sql/src/sql-driver-silent-empty-predicate.test.ts index 86011780f2..7924c6d365 100644 --- a/packages/drivers/driver-sql/src/sql-driver-silent-empty-predicate.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-silent-empty-predicate.test.ts @@ -185,13 +185,23 @@ describe('[#5234] SqlDriver refuses the two comparand shapes that compiled to a expect(err.message).toContain('index 1'); }); - it('the `$field` member refusal from #5041 still answers first, unchanged', async () => { - // A `$field` member is also unbindable, so the two arms overlap. The - // cross-field message is the more actionable one and must keep winning. + it('the `$field` member refusal from #5041 still answers first, operands withheld', async () => { + // A `$field` member is also unbindable, so the two arms overlap, and the + // cross-field arm must keep winning — an unbindable-member message would + // describe the shape without naming the condition. + // + // [#7929] What it no longer does is say WHICH member: `at index 1` named + // the position of a reference the caller may not have written (a read + // scope is ANDed in with the caller's own `where`, and the driver cannot + // tell the two apart). The index moved to the server log with the rest of + // the operands; the sibling assertions above — over ordinary unbindable + // objects, which disclose nothing about a policy — still pin it on the + // wire, so this is a narrowing of the `$field` arm and not of the family. const err = await refusalOf(() => find({ status: { $in: ['a', { $field: 'name' }] } })); expect(err.code).toBe('INVALID_FILTER'); - expect(err.message).toContain('Cross-field comparison'); - expect(err.message).toContain('at index 1'); + expect(err.message).toContain('cross-field comparison'); + expect(err.message).not.toContain('at index 1'); + expect(err.message).not.toContain('name'); }); it('a `$between` bound is a comparand in its own right and gets the same envelope', async () => { diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 7927a5f7fb..97d739bbfe 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -548,7 +548,19 @@ const SQLITE_TIME_EXPR_REFS = 8; * The `[sql-driver]` prefix these messages used to carry is GONE from the text: * it is driver-internal wording, and shipping it to clients is exactly what the * #3867 sanitiser exists to stop. The operator/field/vocabulary detail — the - * part a caller can act on — stays. + * part a caller can act on — stays, EXCEPT where the caller may not be its + * author: see {@link withheldFilterError}. + * + * [#7929, maintainer ruling 2026-08-12] That exception is the cross-field + * `{ $field }` family. A read scope — an administrator's CEL sharing/permission + * rule, compiled by `compileCelToFilter` and ANDed into the query by the + * security middleware (`plugin-security`) or by `ObjectQLStrategy.withReadScope` + * (`service-analytics`) — reaches this compiler as a bare `FilterCondition` with + * NOTHING marking it as policy-authored. When it is refused, the "detail a + * caller can act on" is detail the caller never wrote: policy column names, and + * the identity of the tenant-isolation column. Those refusals therefore keep + * `code` and `status` and lose their operands; the full text goes to the server + * log. The paragraph above still describes every other refusal in this file. */ function unsupportedFilterError(message: string): Error { const err = new Error(message) as Error & { code?: string; status?: number }; @@ -557,6 +569,84 @@ function unsupportedFilterError(message: string): Error { return err; } +/** + * [#7929] The full, operand-naming text of a refusal whose caller-visible + * message was redacted — carried on the Error under a SYMBOL key. + * + * A symbol rather than a string property because the carrier must not travel: + * `JSON.stringify(err)`, `{ ...err }`, `Object.keys`, `for…in` and the + * structured-clone boundary all skip symbol keys, so an error mapper that + * spreads or serialises the error cannot put the text back on the wire — which + * is the one way this redaction could be undone without anyone editing it. + * `Symbol.for` (the global registry) rather than a module-local symbol so a + * duplicated copy of this package still resolves the same key. + */ +const WITHHELD_FILTER_DIAGNOSTIC = Symbol.for('objectstack.driver-sql.withheldFilterDiagnostic'); + +/** + * [#7929] Set on an error once its diagnostic has been written to the log, so + * the seam can be applied at more than one frame without the operator reading + * one refusal twice. Needed because knex invokes a WHERE-group callback lazily, + * which puts nested refusals outside the filter entry point's `catch` — see + * {@link SqlDriver.withWithheldFilterLog}. + */ +const WITHHELD_FILTER_LOGGED = Symbol.for('objectstack.driver-sql.withheldFilterLogged'); + +/** + * [#7929, maintainer ruling 2026-08-12, verbatim: 「接受你的全部建议。」 adopting + * "B now, A next"] An `INVALID_FILTER` refusal that answers the caller with + * `message` and keeps `diagnostic` — the half naming the operands — server-side. + * + * # Why the redaction is unconditional + * + * The driver cannot tell an author's filter from a policy's. Measured on both + * merge boundaries (#7929's analytics capture, #7988's CRUD capture): the + * predicate arrives as a bare `FilterCondition` in `where`, `DriverQuery` is + * `Omit< QueryAST, 'object' >` and `QueryAST` has no provenance slot, and the + * one thing that does cross (`context`) says who is asking, never which subtree + * they did not write. A driver-side guess at provenance is precisely the shape + * the triage lens rejected, so B withholds for EVERY caller. + * + * # BOTH operands are withheld, not just 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. Echoing the target would leave half + * the disclosure live, so the redaction takes both operands, the operator, the + * list index and the boundary `reason` — everything derived from the predicate. + * What stays is the refusal's IDENTITY (`INVALID_FILTER` / 400), which of the + * three cross-field refusal classes fired, and the capability statement, none + * of which is derived from what the caller or the administrator wrote. + * + * # The accepted cost, named rather than hidden + * + * An author debugging their OWN cross-field filter now gets the redacted + * message too, and that is a real diagnostic regression B pays for containment. + * #7929's follow-up card (A: a spec-declared provenance mark set at both merge + * boundaries) is what restores the author-facing text behind a real mark. + * ⛔ Do not "fix" this by re-adding the names, and ⛔ do not widen the REST + * boundary's 5xx-only withhold to 4xx instead (ruled out: it would delete + * #5367's tiering and #5667's legible-undeclared-5xx decision). + */ +function withheldFilterError(message: string, diagnostic: string): Error { + const err = unsupportedFilterError(message); + Object.defineProperty(err, WITHHELD_FILTER_DIAGNOSTIC, { value: diagnostic, enumerable: false }); + return err; +} + +/** + * [#7929] The withheld diagnostic carried by `err`, or `null` for any other + * error. The read half of {@link withheldFilterError} — used by + * {@link SqlDriver.logWithheldFilterDiagnostic} to write the text to the + * server log, and exported so a host that maps driver errors itself can do the + * same rather than re-deriving the seam. + */ +export function withheldFilterDiagnosticOf(err: unknown): string | null { + if (err === null || (typeof err !== 'object' && typeof err !== 'function')) return null; + const text = (err as Record)[WITHHELD_FILTER_DIAGNOSTIC]; + return typeof text === 'string' ? text : null; +} + /** * [#6409] How one declared aggregate function lowers into SQL. * @@ -1009,15 +1099,22 @@ function fieldReferenceOf(value: unknown): string | null { * value portably needs a dialect REPLACE chain nobody has proven; refused. * - **Any other operator** (`$null`, a retired spelling, …): no cross-field * reading exists to compile. + * + * [#7929] The operands, the operator and the list index are withheld from the + * caller-visible half and kept in the server-log half — see + * {@link withheldFilterError} for why, and for what stays. */ function crossFieldComparisonError(field: string, op: string, ref: string, index?: number): Error { const position = index === undefined ? '' : ` at index ${index} of its value list`; - return unsupportedFilterError( + return withheldFilterError( + `A cross-field comparison ({ "$field": … }) in this filter sits in a position SQL ` + + `push-down does not compile. Cross-field comparison compiles only as the whole comparand ` + + `of a scalar comparison operator ($eq/$ne/$gt/$gte/$lt/$lte) between two same-table ` + + `declared columns. Compare against a literal value here, or evaluate the rule in memory ` + + `(matchesFilter). The columns and the operator this filter used are withheld from the ` + + `message (#7929); the full diagnostic is in the server log.`, `Operator "${op}" on field "${field}" compares against another field ` + - `({ "$field": "${ref}" })${position}, a position SQL push-down does not compile. ` + - `Cross-field comparison compiles only as the whole comparand of a scalar comparison ` + - `operator ($eq/$ne/$gt/$gte/$lt/$lte) between two same-table declared columns. ` + - `Compare against a literal value here, or evaluate the rule in memory (matchesFilter).`, + `({ "$field": "${ref}" })${position}, a position SQL push-down does not compile.`, ); } @@ -1048,14 +1145,24 @@ function crossFieldComparisonError(field: string, op: string, ref: string, index * NEW divergence. #7597 ruled that the evaluator's unknown-operator posture * stays as #6520 left it and moved the LOWERING instead, so this message keeps * pointing at the `$eq` spelling that does compile. + * + * [#7929] It points at that spelling with PLACEHOLDER names now. The old text + * wrote the corrected filter out with the two real column names — twice — which + * on a read-scope refusal hands the administrator's policy back to the tenant + * as a suggestion. The SHAPE survives the redaction; the names do not. */ function bareFieldReferenceError(field: string, ref: string): Error { - return unsupportedFilterError( + return withheldFilterError( + `A field in this filter is constrained by a bare field reference ({ "$field": … }) with no ` + + `operator. Write the comparison explicitly — { "TARGET_FIELD": { "$eq": { "$field": ` + + `"OTHER_FIELD" } } } — which compiles to a column-to-column comparison. The bare form is ` + + `refused because the in-memory evaluator does not read it as an equality (it matches no ` + + `record), so compiling it here would make the two execution paths answer this filter ` + + `differently. The columns this filter named are withheld from the message (#7929); the ` + + `full diagnostic is in the server log.`, `Field "${field}" is constrained by a bare field reference ({ "$field": "${ref}" }) with no ` + - `operator. Write the comparison explicitly — { "${field}": { "$eq": { "$field": "${ref}" } } } ` + - `— which compiles to a column-to-column comparison. The bare form is refused because the ` + - `in-memory evaluator does not read it as an equality (it matches no record), so compiling ` + - `it here would make the two execution paths answer this filter differently.`, + `operator. The spelling that compiles is ` + + `{ "${field}": { "$eq": { "$field": "${ref}" } } }.`, ); } @@ -1133,13 +1240,24 @@ function crossFieldComparisonClass( * not own, refused wholesale), and the tenant-isolation column FORBIDDEN on * either side of the comparison. `reason` carries the specific sentence; * this wrapper keeps the envelope and the shared contract statement. + * + * [#7929] `reason` is SERVER-SIDE ONLY now, and it is the sharpest half of this + * card's disclosure rather than an afterthought: those sentences name the + * referenced column, the target column, the object and the declared types — + * and, on the tenant arm, state WHICH column is the tenant-isolation column of + * the object, a fact the platform otherwise keeps to itself. The shared + * contract statement that followed it names nothing and stays on the wire, so a + * caller still learns the capability boundary without learning this filter. */ function uncompilableFieldReferenceError(field: string, op: string, ref: string, reason: string): Error { - return unsupportedFilterError( - `Operator "${op}" on field "${field}" compares against another field ` + - `({ "$field": "${ref}" }), which cannot be compiled here: ${reason} ` + + return withheldFilterError( + `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.`, + `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.`, + `Operator "${op}" on field "${field}" compares against another field ` + + `({ "$field": "${ref}" }), which cannot be compiled here: ${reason}`, ); } @@ -2229,6 +2347,34 @@ function classifyFilterKey(key: string, value: unknown, here: string): FilterVer // local/remote matrix and why `null` is untouched. assertDefinedComparands(key, value, here); + // [#7929, maintainer ruling 2026-08-12] A `{ $field }` comparand at any + // operator OUTSIDE the six that compile one is the CROSS-FIELD refusal — + // answered here, ahead of the comparand-SHAPE gates below. + // + // Not a new refusal: every one of these shapes was already refused, and the + // set of accepted filters is byte-identical. What changes is WHICH refusal + // answers, and that is the point. `$icontains`, `$like`/`$ilike`, `$null` and + // `$exists` each carry their own comparand-shape gate on this walk, and each + // gate renders the offending comparand into its message — so a field + // reference came back to the caller as `received {"$field":"…"}`, naming the + // referenced column, while the same reference at `$contains` (no gate here, + // refused at the emitter) was answered by the cross-field refusal instead. + // Measured on `origin/main`: five operators disclosing, four not, decided by + // nothing a caller can see. One condition — "this comparand is a reference to + // another field, in a position SQL push-down does not compile" — now has one + // answer, and that answer is the redacted one. + // + // The six scalar comparison operators are skipped because a reference IS + // their compiled meaning ({@link SqlDriver.applyCrossFieldComparison}); their + // own boundary refusals are raised there with the same withhold. + if (isFilterNode(value)) { + for (const [op, comparand] of Object.entries(value)) { + if (CROSS_FIELD_COMPARISON_OPERATORS.has(op)) continue; + const ref = fieldReferenceOf(comparand); + if (ref !== null) throw crossFieldComparisonError(key, op, ref); + } + } + // [#5347] `$null`'s comparand is a boolean by declaration. Checked on this // walk rather than in the emitter's `$null` arm for the same // evaluation-order reason, and checked on the RAW value so the message names @@ -8487,7 +8633,79 @@ export class SqlDriver implements IDataDriver { throw jsonColumnOperatorError(column, op, bare); } + /** + * [#7929] Compile `filters` into `builder`, and write the server-side half of + * any REDACTED refusal to the log on the way out. + * + * This method is the single production entry into the filter compiler — every + * `find` / `count` / `aggregate` / `update` / `delete` path in this file + * reaches {@link SqlDriver.applyFilterCondition} through it, and that method + * recurses only into itself. So one `catch` here sees every refusal the three + * `{ $field }` builders raise ({@link crossFieldComparisonError}, + * {@link bareFieldReferenceError}, {@link uncompilableFieldReferenceError}) + * without a per-throw-site call, and without a re-entrancy guard. + * + * The log is `this.logger` — the sink this driver already owns, which a host + * injects and a test spies on. ⛔ Not `console.warn` from the module-level + * builders: that would bypass an injected sink, which is the whole reason the + * property exists. + * + * The rethrow is the original error, untouched: the caller-visible message, + * `code` and `status` are the redacted refusal's own, and the diagnostic + * stays on the symbol key where nothing that serialises an error can reach it. + */ protected applyFilters(builder: Knex.QueryBuilder, filters: any) { + try { + this.compileFilters(builder, filters); + } catch (err) { + this.logWithheldFilterDiagnostic(err); + throw err; + } + } + + /** + * [#7929] Write the withheld half of a redacted refusal to the server log. + * + * A no-op for every other error, so the `catch` above stays a pass-through + * for the refusals that were never redacted (they say everything they have to + * say on the wire already). + */ + protected logWithheldFilterDiagnostic(err: unknown): void { + const diagnostic = withheldFilterDiagnosticOf(err); + if (diagnostic === null) return; + const marked = err as Record; + if (marked[WITHHELD_FILTER_LOGGED] === true) return; + Object.defineProperty(err as object, WITHHELD_FILTER_LOGGED, { value: true, enumerable: false }); + this.logger.warn( + `[sql-driver] INVALID_FILTER — cross-field reference refused; operands withheld from the ` + + `response (#7929). Full diagnostic: ${diagnostic}`, + ); + } + + /** + * [#7929] Run `fn`, writing the server-side half of a redacted refusal before + * rethrowing it. + * + * Applied at every KNEX GROUP CALLBACK as well as at {@link applyFilters}, + * and that is a measurement rather than belt-and-braces: knex invokes a + * group callback LAZILY, when the statement is compiled — after + * `applyFilters` has already returned — so the entry point's `catch` never + * sees a refusal raised inside one. Measured before this method existed: a + * `$or`-nested cross-field refusal reached the caller correctly redacted + * while the server log stayed completely silent, which is the worst of both + * halves. {@link logWithheldFilterDiagnostic} marks the error, so an error + * that passes two of these frames is still logged once. + */ + protected withWithheldFilterLog(fn: () => T): T { + try { + return fn(); + } catch (err) { + this.logWithheldFilterDiagnostic(err); + throw err; + } + } + + private compileFilters(builder: Knex.QueryBuilder, filters: any) { if (!filters) return; // [#5158] `where` is a `FilterCondition` OBJECT. It always was — the spec @@ -8790,7 +9008,7 @@ export class SqlDriver implements IDataDriver { (builder as any)[method]((qb: any) => { for (const sub of branches) { qb.where((subQb: any) => { - this.applyFilterCondition(subQb, sub, 'and', table); + this.withWithheldFilterLog(() => this.applyFilterCondition(subQb, sub, 'and', table)); }); } }); @@ -8815,7 +9033,7 @@ export class SqlDriver implements IDataDriver { // read scope of the shape `{$or:[{owner,status},{shared_with}]}` // returned rows the scope excluded — see sql-driver-or-filter.test.ts. qb.orWhere((subQb: any) => { - this.applyFilterCondition(subQb, sub, 'and', table); + this.withWithheldFilterLog(() => this.applyFilterCondition(subQb, sub, 'and', table)); }); } }); @@ -8841,7 +9059,7 @@ export class SqlDriver implements IDataDriver { const negated = nullSafeNegationOperand(value as Record); const notMethod = logicalOp === 'or' ? 'orWhereNot' : 'whereNot'; (builder as any)[notMethod]((qb: any) => { - this.applyFilterCondition(qb, negated, 'and', table); + this.withWithheldFilterLog(() => this.applyFilterCondition(qb, negated, 'and', table)); }); } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { const localField = this.mapSortField(key); diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-cross-field-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-cross-field-conformance.test.ts index de9362b705..e70b54aacc 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-cross-field-conformance.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-cross-field-conformance.test.ts @@ -34,6 +34,7 @@ import { CROSS_FIELD_AUTHORED_CASES, CROSS_FIELD_CASES, CROSS_FIELD_OBJECT_FIELDS, + CROSS_FIELD_OPERAND_NAMES, CROSS_FIELD_REFUSALS, CROSS_FIELD_ROWS, } from '@objectstack/driver-sql'; @@ -114,12 +115,25 @@ describe('[#5222] driver-sqlite-wasm — cross-field `$field` push-down conforma 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] The wasm driver inherits the withhold with the compiler, and + // "inherits it, therefore it is fine" is exactly what this file exists + // to disprove — the sink is `SqlDriver.logger`, and a subclass that + // replaced the logger or the filter entry point would drop the + // server-side half while the response still looked right. + 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'); @@ -127,8 +141,15 @@ describe('[#5222] driver-sqlite-wasm — cross-field `$field` push-down conforma 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); + // The disclosure half: neither operand reaches the caller. + for (const name of CROSS_FIELD_OPERAND_NAMES) { + expect(error!.message, `caller-visible message names "${name}"`).not.toContain(name); + } + // …and the diagnostic half: the wording that says WHICH ruling bit is + // in the server log, so the refusal is still debuggable by an operator. + const diagnostic = logged.join('\n'); + for (const fragment of refusal.diagnosticIncludes) { + expect(diagnostic, `server log lost "${fragment}"`).toContain(fragment); } }); } diff --git a/packages/drivers/driver-turso/src/remote-transport-comparand-refusal.test.ts b/packages/drivers/driver-turso/src/remote-transport-comparand-refusal.test.ts index 3c5093905f..29a424285b 100644 --- a/packages/drivers/driver-turso/src/remote-transport-comparand-refusal.test.ts +++ b/packages/drivers/driver-turso/src/remote-transport-comparand-refusal.test.ts @@ -111,11 +111,39 @@ describe('RemoteTransport comparand refusal — the value half of #1004', () => expect(calls).toHaveLength(0); }); - it('names the operator, the column and the offending value in the message', async () => { + it('[#7929] names the operator, the columns and the value to the LOG — never to the caller', async () => { + // This case is the inversion of what it used to assert, and the + // inversion IS the change. It pinned `'deal.amount' $gt + // {"$field":"budget"}` in the message a caller receives; measured on + // #7929, that predicate is routinely an ADMINISTRATOR's — the security + // middleware ANDs a compiled CEL sharing rule into the same `where`, and + // this transport cannot tell the two apart. So the operands moved to the + // diagnostic sink `TursoDriver` wires to its logger, and what the caller + // gets keeps `INVALID_FILTER` / 400 and the capability sentence. + // + // BOTH halves are asserted here: a refusal that said nothing at all + // would pass the disclosure half alone, and the old message would pass + // the diagnostic half alone. const { t } = transportWithCapturingClient(); - await expect(t.find('deal', { where: { amount: { $gt: { $field: 'budget' } } } })).rejects.toThrow( - /'deal\.amount' \$gt \{"\$field":"budget"\}/, - ); + const logged: string[] = []; + t.setDiagnosticSink((m) => { logged.push(m); }); + const err = await t.find('deal', { where: { amount: { $gt: { $field: 'budget' } } } }).catch((e) => e); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).not.toContain('budget'); + expect(err.message).not.toContain('amount'); + expect(logged.join('\n')).toMatch(/'deal\.amount' \$gt \{"\$field":"budget"\}/); + }); + + it('[#7929] the withheld text is not reachable by serialising the error', async () => { + // The carrier is a symbol key, and this is what that buys: an error + // mapper that spreads or stringifies the error — the ordinary way an + // envelope is built — cannot put the operands back on the wire, so the + // withhold cannot be undone downstream by code that never heard of it. + const { t } = transportWithCapturingClient(); + const err = await t.find('deal', { where: { amount: { $gt: { $field: 'budget' } } } }).catch((e) => e); + expect(JSON.stringify({ ...err, message: err.message })).not.toContain('budget'); + expect(Object.keys(err)).not.toContain('diagnostic'); }); it('says what to do instead — not "try another operator", which fails identically', async () => { @@ -141,11 +169,19 @@ describe('RemoteTransport comparand refusal — the value half of #1004', () => expect(calls).toHaveLength(0); }); - it('refuses the marker as an `$in` element, naming which element', async () => { + it('refuses the marker as an `$in` element, naming which element — in the log (#7929)', async () => { + // Which ELEMENT is as much the policy's shape as the column names are: + // a read scope's `$in` list is the administrator's, so the index goes to + // the same place the operands went. Still refused, still `INVALID_FILTER`. const { t } = transportWithCapturingClient(); - await expect(t.find('deal', { where: { id: { $in: ['a', { $field: 'other_id' }] } } })).rejects.toThrow( - /'deal\.id' \$in\[1\]/, - ); + const logged: string[] = []; + t.setDiagnosticSink((m) => { logged.push(m); }); + const err = await t + .find('deal', { where: { id: { $in: ['a', { $field: 'other_id' }] } } }) + .catch((e) => e); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).not.toContain('other_id'); + expect(logged.join('\n')).toMatch(/'deal\.id' \$in\[1\]/); }); }); diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index cb366cb68c..edcb699c31 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -539,6 +539,39 @@ function invalidFilterError(message: string): Error { return err; } +/** + * [#7929] The key `driver-sql` carries a REDACTED refusal's full diagnostic + * under — the same global-registry symbol, deliberately, not a second one. + * + * `TursoDriver extends SqlDriver`, so one deployment can raise the refusal from + * either compiler depending on transport mode, and a host that reads the text + * off the error (`withheldFilterDiagnosticOf`, exported by + * `@objectstack/driver-sql`) must not have to know which one answered. A symbol + * rather than a string property for the reason stated there: `JSON.stringify`, + * object spread and structured clone all skip it, so no error mapper can put + * the withheld text back on the wire. + */ +const WITHHELD_FILTER_DIAGNOSTIC = Symbol.for('objectstack.driver-sql.withheldFilterDiagnostic'); + +/** + * [#7929, maintainer ruling 2026-08-12] An `INVALID_FILTER` whose caller-visible + * message is `message` and whose operand-naming half is `diagnostic`. + * + * The remote twin of `driver-sql`'s `withheldFilterError`, and it exists for the + * same measured reason one package over: an administrator's CEL sharing rule + * compiles to `{ $field: … }` and is ANDed into the query by the security + * middleware, so a refusal that echoes the reference — and the column it was + * compared against — hands a tenant a policy they never saw. This transport is + * `TursoDriver`'s REMOTE compiler; local mode inherits `SqlDriver`'s, which + * withholds the same way. Leaving one mode disclosing would make the exposure a + * property of the connection string. + */ +function withheldInvalidFilterError(message: string, diagnostic: string): Error { + const err = invalidFilterError(message); + Object.defineProperty(err, WITHHELD_FILTER_DIAGNOSTIC, { value: diagnostic, enumerable: false }); + return err; +} + /** * [#6409] How one declared aggregate function lowers into SQL — the twin of * `driver-sql`'s `SqlAggregateLowering`. @@ -805,6 +838,28 @@ export class RemoteTransport { */ private filterColumnSql: FilterColumnSqlResolver | null = null; + /** + * [#7929] Where the withheld half of a redacted refusal is written. + * + * This class owns no logger — it never has — and #7929 is not the card that + * gives it one: inventing a logging subsystem here would be a second sink to + * configure, beside the one `SqlDriver` already exposes and hosts already + * wire. `TursoDriver` (which extends `SqlDriver`) hands its own + * `logger.warn` down during construction, exactly the way it hands down the + * connect factory and the temporal column rule. Absent, the diagnostic stays + * on the error's symbol key and reaches the log only if the host reads it — + * which is the honest degradation, not a silent one. + */ + private diagnosticSink: ((message: string) => void) | null = null; + + /** + * Register where this transport writes the server-side half of a refusal + * whose caller-visible message was redacted (#7929). + */ + setDiagnosticSink(sink: (message: string) => void): void { + this.diagnosticSink = sink; + } + /** * Set the @libsql/client instance used for all queries. */ @@ -2925,12 +2980,24 @@ export class RemoteTransport { const target = `'${object}.${field}'`; const shown = `${op} ${preview(value)}`; if (isFieldReference(value)) { - return invalidFilterError( - `[RemoteTransport] Cross-field comparison is not supported in remote mode: ${target} ${shown} ` + - `compares a column against another column instead of against a value. The query DSL declares ` + - `this form (spec FieldReferenceSchema) but no executor compiles it, so it is refused here ` + - `rather than bound as the marker's JSON text — which is valid SQL that matches nothing ` + - `(#1058). Compare against a literal, or select both columns and compare after retrieval.`, + // [#7929] Both operands are withheld, not just the referenced one. On a + // read-scope refusal the administrator wrote the target column as surely + // as the reference — `{ amount: { $gt: { $field: 'secret_policy_col' } } }` + // is one predicate, authored once — so echoing `'deal.amount'` while + // hiding the reference would leave half the policy on the wire. + const diagnostic = + `[RemoteTransport] Cross-field comparison is not supported in remote mode: ${target} ` + + `${shown} compares a column against another column instead of against a value.`; + this.diagnosticSink?.(diagnostic); + return withheldInvalidFilterError( + `[RemoteTransport] Cross-field comparison is not supported in remote mode: this filter ` + + `compares a column against another column instead of against a value. The query DSL ` + + `declares this form (spec FieldReferenceSchema) but no executor compiles it, so it is ` + + `refused here rather than bound as the marker's JSON text — which is valid SQL that ` + + `matches nothing (#1058). Compare against a literal, or select both columns and compare ` + + `after retrieval. The columns and the operator this filter used are withheld from the ` + + `message (#7929); the full diagnostic is in the server log.`, + diagnostic, ); } return invalidFilterError( diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 18ac756d79..96af6ac048 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -362,6 +362,16 @@ export class TursoDriver extends SqlDriver { this.temporalFilterColumnSql(object, field, columnSql), ); + // [#7929] The server-side half of a REDACTED filter refusal. The remote + // compiler withholds the operands of a cross-field comparison for the + // same reason the inherited local one does — an RLS rule's columns are + // not the caller's to read — and this line is what stops the withheld + // text from being withheld from the OPERATOR too. `this.logger` is + // `SqlDriver`'s own sink, so a host that injected a logger for local mode + // gets remote mode's diagnostics in the same place, and a deployment + // cannot lose them by changing its connection string. + this.remoteTransport.setDiagnosticSink((message) => this.logger.warn(message)); + // Register a lazy-connect factory so the transport can self-heal when // connect() was never called, failed on first attempt, or the client // was lost (e.g. serverless cold-start, transient network error). diff --git a/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts b/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts new file mode 100644 index 0000000000..235c0696c6 --- /dev/null +++ b/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts @@ -0,0 +1,393 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7929 / #7988, maintainer ruling 2026-08-12 — option B] A cross-field + * `{ $field }` refusal from `driver-sql` is still a refusal, and no longer + * discloses the predicate — on BOTH merge boundaries, and for the author too. + * + * ## What was measured, and why it needed a ruling + * + * An administrator's CEL sharing/permission rule compiles to `{ $field: path }` + * (`compileCelToFilter`) and is ANDed into the caller's query by whichever + * boundary is in play. When the reference fails one of the four #5222 rulings, + * `driver-sql` refused with `INVALID_FILTER` / 400 and the message named the + * policy — the referenced column, the target column, and, on the tenant arm, + * WHICH column is the tenant-isolation column of the object. The caller wrote + * none of it. Four captured response bodies are on #7929; the sharpest returned + * `sharing_rule.manager_budget`, pure sharing-rule content, inside + * `error.message`. + * + * ⛔ The obvious repair was ruled out before this file existed: widening + * `errorResponseBase`'s withhold to 4xx would delete #5367's deliberate + * 5xx-only tiering AND #5667's legible-undeclared-5xx decision. The 400 here is + * a DECLARED 4xx and passes that boundary intact — by design, and pinned in + * `analytics-query-read-scope-withhold.test.ts` as "a DECLARED 4xx is + * untouched". So the withhold lands at the driver, and this file is where that + * choice is checked end-to-end rather than at the unit that implements it. + * + * ## Why BOTH paths, in one file + * + * "The merge boundary" is two boundaries in two packages (#7988): + * + * | path | who merges the admin's filter | package | + * |---|---|---| + * | `POST /analytics/query` | `ObjectQLStrategy.withReadScope` | `service-analytics` | + * | ordinary CRUD read | security middleware → `opCtx.ast.where` | `plugin-security` | + * + * Both hand `driver-sql` an unmarked `FilterCondition`, and the CRUD one + * PREDATES the routing that made the analytics one reachable (#5222, not + * #7598). A fix pinned only on the analytics face would leave the larger face + * open with every gate green, which is exactly the failure #7988 was filed to + * prevent. The withhold is one seam in the driver, so one seam is what both + * arms below exercise — through the real REST route for the analytics face, and + * through a real `ObjectQL` engine with a security-middleware-shaped injection + * for the CRUD face. + * + * ## The author's case is here on purpose + * + * B withholds for EVERY caller, because the driver cannot tell an author's + * filter from a policy's — `DriverQuery` carries no provenance and the two + * messages were byte-identical before this change. So an author debugging their + * own cross-field filter now gets the redacted message too. That is a real + * diagnostic regression, ruled an accepted cost until #7929's follow-up (A: a + * spec-declared provenance mark set at both boundaries) restores the + * author-facing text behind a real mark. It is pinned below rather than left + * implicit, so that "the author still sees the columns" cannot be restored by + * accident — it would reopen the disclosure on every unmarked policy predicate. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { CROSS_FIELD_OBJECT_FIELDS, CROSS_FIELD_ROWS } from '@objectstack/driver-sql'; +import { AnalyticsService } from '@objectstack/service-analytics'; +import type { AnalyticsQuery, DriverQuery } from '@objectstack/spec/contracts'; +import type { AggregationNode, Cube, FilterCondition } from '@objectstack/spec/data'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const OBJECT = 'cross_field_deal'; + +/** + * The names a refusal must not put in front of a caller: the two operands of + * every scope below, the object, and the columns the corpus declares. + * + * `sharing_rule.manager_budget` is listed by both spellings — the dotted path + * and its head — because a message that printed only the head would still be + * naming an administrator's sharing rule. + */ +const POLICY_NAMES = [ + 'organization_id', + 'secret_policy_column', + 'sharing_rule.manager_budget', + 'sharing_rule', + 'manager_budget', + 'amount', + 'stage', + 'budget', +]; + +/** + * The four read scopes captured on #7929, verbatim, plus the composed case. + * + * Each one is what `compileCelToFilter` emits for a field-to-field comparison + * in an admin-authored rule, and each fails a different one of the four #5222 + * rulings — so the arm below is not four spellings of one code path. + */ +const CAPTURED_SCOPES: Array<[string, FilterCondition]> = [ + ['the tenant-isolation column as referent', { stage: { $eq: { $field: 'organization_id' } } } as FilterCondition], + ['an undeclared policy column', { amount: { $gt: { $field: 'secret_policy_column' } } } as FilterCondition], + ['a dotted sharing-rule path', { amount: { $gt: { $field: 'sharing_rule.manager_budget' } } } as FilterCondition], + ['a cross-class comparison', { stage: { $gt: { $field: 'amount' } } } as FilterCondition], +]; + +/** Every column of the corpus fixture, as a plain cube dimension. */ +const CUBE: Cube = { + name: 'deals', + sql: OBJECT, + measures: { n: { sql: '*', type: 'count', title: 'n' } }, + dimensions: Object.fromEntries( + ['id', 'amount', 'budget', 'stage', 'owner', 'starts_on', 'ends_on', 'organization_id'].map( + (n) => [n, { name: n, label: n, type: 'string', sql: n }], + ), + ), + public: false, +} as unknown as Cube; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +// ── the REST harness (the shape `dispatcher-plugin.error-envelope.test.ts` uses) ── + +function makeFakeServer() { + const handlers: Record any> = {}; + const rec = (verb: string) => (path: string, handler: any) => { + handlers[`${verb} ${path}`] = handler; + }; + return { + handlers, + server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') }, + }; +} + +function makeCtx(fakeServer: any, analytics: unknown) { + const kernel = { + getService: (name: string) => (name === 'analytics' ? analytics : undefined), + getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : undefined), + }; + return { + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? fakeServer : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, + on: () => {}, + } as any; +} + +function makeRes() { + const res: any = { + statusCode: undefined as number | undefined, + body: undefined as any, + status(c: number) { res.statusCode = c; return res; }, + header() { return res; }, + json(b: any) { res.body = b; return res; }, + }; + return res; +} + +/** Drive the REAL `POST /api/v1/analytics/query` route against `analytics`. */ +async function postAnalyticsQuery(analytics: unknown, body: unknown) { + const { server, handlers } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server, analytics)); + + const handler = handlers['POST /api/v1/analytics/query']; + expect(handler, 'POST /api/v1/analytics/query must be mounted').toBeTypeOf('function'); + + const res = makeRes(); + await handler({ body, query: {} }, res); + return res; +} + +describe('[#7929] a cross-field refusal keeps its envelope and stops disclosing the predicate', () => { + let driver: SqliteWasmDriver; + /** Everything the driver wrote to its log during one run. */ + let logged: string[]; + /** Every `executeRawSql` the analytics arm made — the decline's control. */ + let rawSqlCalls: string[]; + /** The scope `getReadScope` answers with, swapped per case. */ + let readScope: FilterCondition | null; + let analytics: AnalyticsService; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects([{ name: OBJECT, fields: CROSS_FIELD_OBJECT_FIELDS } as any]); + for (const row of CROSS_FIELD_ROWS) await driver.create(OBJECT, { ...row }); + + logged = []; + rawSqlCalls = []; + readScope = null; + // The server-side half has to land SOMEWHERE for the withhold to be a + // relocation rather than a deletion. `logger` is the sink `SqlDriver` + // already owns and a host already injects; spying on it is what a host + // wiring a real logger would see. + (driver as unknown as { logger: unknown }).logger = { + warn: (m: string) => { logged.push(String(m)); }, + error: () => {}, + info: () => {}, + }; + + analytics = new AnalyticsService({ + cubes: [CUBE], + // BOTH paths advertised, so `NativeSQLStrategy` (priority 10) wins every + // query unless it DECLINES. `rawSqlCalls` staying empty is therefore a + // measurement of the #7598 Q1=B decline, not of a missing capability. + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + executeRawSql: async (_object: string, sql: string) => { rawSqlCalls.push(sql); return []; }, + executeAggregate: async (objectName: string, options: any) => { + const query: DriverQuery = { + where: options.filter as FilterCondition, + groupBy: options.groupBy, + aggregations: options.aggregations?.map(({ field, method, alias }: any) => ({ + field, + function: method as AggregationNode['function'], + alias, + })), + }; + return (await driver.aggregate(objectName, query)) as Record[]; + }, + getReadScope: () => (readScope ?? undefined) as never, + } as never); + }); + + afterAll(async () => { + await driver?.disconnect?.(); + }); + + // ── (a) the analytics face — the four captured bodies ──────────────────── + + describe('the analytics route (`POST /analytics/query`, read scope merged by service-analytics)', () => { + for (const [name, scope] of CAPTURED_SCOPES) { + it(`${name} → 400 INVALID_FILTER, and the response names no part of the policy`, async () => { + logged = []; + rawSqlCalls = []; + readScope = scope; + const res = await postAnalyticsQuery(analytics, { + cube: 'deals', + dimensions: ['id'], + measures: ['n'], + } as AnalyticsQuery); + readScope = null; + + // Still refused, and refused the same way: this is the half a + // disclosure fix is most likely to break, so it is asserted first. + expect(res.body?.success).toBe(false); + expect(res.body?.error?.code).toBe('INVALID_FILTER'); + expect(res.body?.error?.httpStatus ?? res.statusCode).toBe(400); + // The native emitter never saw the query — the decline put it on the + // engine path, which is the only road that reaches the driver's gate. + expect(rawSqlCalls, 'NativeSQLStrategy did not decline').toEqual([]); + + const message = String(res.body?.error?.message ?? ''); + for (const policyName of POLICY_NAMES) { + expect(message, `the response names "${policyName}"`).not.toContain(policyName); + } + // …and the operator's copy is intact, so this is a relocation. + expect(logged.join('\n'), 'the server log lost the diagnostic').toContain('$field'); + }); + } + + it('a caller `where` composed with a refused scope discloses nothing either', async () => { + // The composed case from the #7929 capture: the caller wrote + // `stage = 'won'`, the administrator wrote the reference, and the driver + // receives `{ $and: [ … ] }` with nothing saying which arm is whose. + // That indistinguishability is the whole reason B withholds for everyone. + logged = []; + rawSqlCalls = []; + readScope = { amount: { $gt: { $field: 'secret_policy_column' } } } as FilterCondition; + const res = await postAnalyticsQuery(analytics, { + cube: 'deals', + dimensions: ['id'], + measures: ['n'], + where: { stage: 'won' }, + } as unknown as AnalyticsQuery); + readScope = null; + + expect(res.body?.error?.code).toBe('INVALID_FILTER'); + expect(String(res.body?.error?.message)).not.toContain('secret_policy_column'); + expect(rawSqlCalls).toEqual([]); + }); + }); + + // ── (b) the CRUD face — #7988's measurement ────────────────────────────── + + describe('the ordinary CRUD read (read filter merged by the security middleware)', () => { + let ql: ObjectQL; + /** + * The admin-authored predicate the middleware ANDs in, swapped per case. + * + * Carried in a closure rather than passed through `find`'s options + * deliberately: the engine REFUSES an undeclared option + * (`rejectUnknownEngineOptions`), and more to the point a real read scope + * never arrives as a caller argument — it is injected by middleware the + * caller cannot see, which is the entire premise of this card. + */ + let crudScope: FilterCondition | null = null; + + beforeAll(async () => { + ql = new ObjectQL(); + ql.registerDriver(driver as never, true); + await ql.init(); + ql.registerObject({ + name: OBJECT, + label: 'Cross field deal', + fields: CROSS_FIELD_OBJECT_FIELDS, + } as never); + // Shaped exactly like `plugin-security`'s injection + // (`security-plugin.ts`: `ast.where = ast.where ? { $and: [ast.where, + // …extra] } : extra[0]`), because the claim under test is about what + // THAT produces — an admin predicate the caller never wrote, in the same + // `where` as the caller's own. + ql.registerMiddleware(async (ctx: any, next: () => Promise) => { + if (['find', 'findOne', 'count', 'aggregate'].includes(ctx.operation) && crudScope) { + const ast: any = ctx.ast ?? { object: ctx.object }; + ast.where = ast.where ? { $and: [ast.where, crudScope] } : crudScope; + ctx.ast = ast; + } + await next(); + }); + }); + + const readWithScope = async ( + scope: FilterCondition | null, + where?: FilterCondition, + ): Promise<{ err: WireBearingError; logged: string }> => { + logged = []; + crudScope = scope; + let err: WireBearingError | null = null; + try { + await ql.find(OBJECT, (where ? { where } : {}) as never); + } catch (e) { + err = e as WireBearingError; + } finally { + crudScope = null; + } + if (!err) throw new Error('expected the read to be refused, but it returned rows'); + return { err, logged: logged.join('\n') }; + }; + + it('an injected read filter with a refused reference → 400 INVALID_FILTER, policy withheld', async () => { + // #7988's measurement, re-run against the fix. This path has gone + // straight to `driver-sql` since #5222 — it never needed #7598's routing + // — so a fix scoped to the analytics face would have left it wide open. + const { err, logged: log } = await readWithScope( + { amount: { $gt: { $field: 'secret_policy_column' } } } as FilterCondition, + { stage: 'won' } as FilterCondition, + ); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + for (const policyName of ['secret_policy_column', 'amount', 'stage']) { + expect(err.message, `the refusal names "${policyName}"`).not.toContain(policyName); + } + expect(log).toContain('secret_policy_column'); + }); + + it('the tenant-isolation column stays unnamed on this face too', async () => { + // The one fact the platform otherwise keeps entirely to itself: the old + // message stated WHICH column is the tenant-isolation column of the + // object, to a caller who asked about neither. + const { err, logged: log } = await readWithScope( + { stage: { $eq: { $field: 'organization_id' } } } as FilterCondition, + ); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).not.toContain('organization_id'); + expect(log).toContain('organization_id'); + }); + + // ── (c) the honest author pays the same price, deliberately ──────────── + + it('an AUTHOR-written `$field` filter gets the identical redacted message', async () => { + // The accepted cost of B, pinned as an equality rather than described. + // Byte-identical is the strongest available statement of "the driver + // cannot tell these apart", and it is also the regression guard for A: + // when the provenance mark lands, THIS assertion is the one that must be + // rewritten deliberately, in the card that restores the author's text. + const policyAuthored = await readWithScope( + { amount: { $gt: { $field: 'secret_policy_column' } } } as FilterCondition, + ); + const authorWritten = await readWithScope( + null, + { amount: { $gt: { $field: 'secret_policy_column' } } } as FilterCondition, + ); + expect(authorWritten.err.code).toBe('INVALID_FILTER'); + expect(authorWritten.err.status).toBe(400); + expect(authorWritten.err.message).toBe(policyAuthored.err.message); + // The author's own diagnostic is not destroyed — it is relocated to the + // server log, which is where an operator can still answer their ticket. + expect(authorWritten.logged).toContain('secret_policy_column'); + }); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts index d62040da63..d652784844 100644 --- a/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts +++ b/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts @@ -62,6 +62,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { CROSS_FIELD_CASES, CROSS_FIELD_OBJECT_FIELDS, + CROSS_FIELD_OPERAND_NAMES, CROSS_FIELD_REFUSALS, CROSS_FIELD_ROWS, } from '@objectstack/driver-sql'; @@ -272,6 +273,25 @@ describe('[#7598] cross-field `$field` on the analytics face — served via the // Never a bind-layer accident dressed up as a refusal. expect(err).not.toBeInstanceOf(TypeError); expect(err.message).not.toContain('can only bind'); + // [#7929, maintainer ruling 2026-08-12 — B] The ROUTED half is the one + // the ruling changed: those refusals are `driver-sql`'s, and the driver + // no longer echoes either operand, because on a read-scope query both + // columns were written by an administrator the caller never saw. This + // is the analytics face of that pin — the four captured response bodies + // on #7929 all came down this road. + // + // ⛔ The `routed === false` half is deliberately NOT asserted the same + // way. Those refusals never reach a driver: this package answers them + // itself (`fieldReferenceBetweenBoundMessage` and the `$in`/LIKE arms), + // with wording that still names both operands. That is a services-lane + // surface and B is scoped to the driver, so the gap is recorded rather + // than closed here — asserting it green would be asserting something + // this change did not do. + if (routed) { + for (const column of CROSS_FIELD_OPERAND_NAMES) { + expect(err.message, `driver refusal names "${column}"${note}`).not.toContain(column); + } + } // The native-SQL emitter never saw it either way — declined, or refused // before a strategy was chosen. expect(rawSqlCalls).toEqual([]); @@ -299,7 +319,7 @@ describe('[#7598] cross-field `$field` on the analytics face — served via the // acceptance criterion. const routed = CROSS_FIELD_REFUSALS.filter((r) => findCrossFieldComparand(r.filter)); const covers = (fragment: string) => - routed.some((r) => r.messageIncludes.some((m) => m.includes(fragment))); + routed.some((r) => r.diagnosticIncludes.some((m) => m.includes(fragment))); expect(covers('dotted path'), 'ruling 1: same-table columns only').toBe(true); expect(covers('not a declared field'), 'ruling 2: declared-only enumeration').toBe(true); expect(covers('tenant-isolation column'), 'ruling 2, security half').toBe(true); @@ -307,7 +327,7 @@ describe('[#7598] cross-field `$field` on the analytics face — served via the // Both SIDES of the tenant ban — `=` commutes, so a ban a swap walks // around is not a ban. expect( - routed.filter((r) => r.messageIncludes.includes('tenant-isolation column')).length, + routed.filter((r) => r.diagnosticIncludes.includes('tenant-isolation column')).length, ).toBeGreaterThanOrEqual(2); }); }); From 29c65ea74404bb9120349c394a4b6089d61d7b02 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 20:25:45 +0000 Subject: [PATCH 2/2] chore(changeset): raise the #7929 withhold to minor on both driver packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VoxQqG5FiUHZKCST7KDoZC --- .changeset/withhold-cross-field-operands.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/withhold-cross-field-operands.md b/.changeset/withhold-cross-field-operands.md index d8fae8fa80..5b7a85c59f 100644 --- a/.changeset/withhold-cross-field-operands.md +++ b/.changeset/withhold-cross-field-operands.md @@ -1,6 +1,6 @@ --- -"@objectstack/driver-sql": patch -"@objectstack/driver-turso": patch +"@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)