Skip to content

Commit 57c2b73

Browse files
fix(driver-sql, driver-turso): four filter-refusal doors read the provenance mark before naming a read scope field or comparand (#20020) (#20037)
Part of #20020. This PR covers the four `driver-sql` refusal doors and their twins in the Turso remote transport. The three engine doors the card also names are not changed: measured, they see no provenance mark on any host except the analytics face, so applying the rule there would hide every CRUD caller's own diagnostic. That needs a decision; see "The engine half" below. Clause-②: no ## What changed A read scope is the RLS, sharing or tenant predicate that `plugin-security` (ordinary reads) and `service-analytics` (`ObjectQLStrategy.withReadScope`) AND into the caller's `where`. Both merges mark the scope `'policy'` and the caller's own predicate `'author'` (#8220, `packages/spec/src/data/filter-subtree-provenance.ts`). The contract for `'policy'`: "A refusal raised from inside it keeps the #7929 redaction: identity (`INVALID_FILTER` / 400) and capability statement on the wire, operands in the server log." And the fail direction: "Unmarked or ambiguous ⇒ withheld." Four `SqlDriver` refusal doors did not read the mark. They now go through the seam the cross-field (#7929) and target-field (#8197) refusals already use: `withheldFilterError`, resolved in `applyFilters` / `withWithheldFilterLog` with `resolveFilterSubtreeProvenance`. - **Retired or unknown operator** (the emitter's `default:` arm): `retiredFilterOperatorError` and the new `unsupportedFilterOperatorError` hand over the field's operator map as the node. - **Non-boolean `$null` / `$exists`** (`classifyFilterKey`): the field's operator map is the node. - **`$and` / `$or` whose operand is not a list** (`assertFilterNodeList`): the operand is the node when it is an object. A primitive operand is judged by the node that carries the key. The shared walk in `@objectstack/spec` hands that hook only the operand, the key and the path, so `sqlFilterVerdictHooks` rebuilds the node from the path. It records every node the walk announces through `assertNode`, keyed by paths made only of combinator keys and list indices. The spec hook signature is unchanged. - **A WHERE column the table does not have** (`unresolvableFilterColumnRefusal`, raised after the statement ran): the dialect reports only a column name. `unresolvableColumnProvenance` finds every node in the query's `where` whose key (as written, through `mapSortField`, or through an external column map) or `{ $field }` names that column. The name reaches the wire only when there is at least one such node and every one resolves `'author'`. Otherwise the refusal uses the unnamed wording the method already had for a dialect message it could not parse. The four callers (`find`, `count`, `aggregate`, `distinct`) now pass their `where`. Code and status are unchanged on every path: `INVALID_FILTER` / 400. The set of refused filters is unchanged. An `'author'`-marked subtree gets the text the door answered before. The source templates are the same literals, now passed as the diagnostic and the author text. **Turso remote transport (the claim's conditional arm).** `RemoteTransport` has its own copies of three of these doors, and they did not read the mark either: - `unsupportedOperator`, for a retired or unknown operator. The same method's arm for a non-operator key in an operator map names the target the same way and is converted with it. - `nonListCombinator`. - `nonBooleanNullComparand` / `nonBooleanExistsComparand`. They now go through `withheldInvalidFilterError`, and the new `withheldRefusal` helper sends the diagnostic to the sink. The transport has no refusal for an unresolvable WHERE column: `find` answers `[]`. That door is not touched here. `pushLike` is not touched. ## Measured: what each 400 body carried, before → after Probe (scratch, not committed): a real `ObjectQL` over `SqliteWasmDriver` (a `SqlDriver` subclass). The analytics face went through the real `POST /api/v1/analytics/query` route: `AnalyticsService` in ObjectQL-only mode, with `getReadScope` returning the scope. The CRUD face went through `ql.find` under a middleware that copies `plugin-security`'s merge and marking code. Before = base `26550c660`. After = this branch, with `driver-sql` dist rebuilt. The scope used distinctive names (`policy_stage`, `PSECRET…`), and a row counts as a leak when one of them appeared in the response message. | refusal class | door | analytics face, policy scope | CRUD face, policy scope | |---|---|---|---| | column the table does not have | driver-sql | field → nothing | field → nothing | | retired operator (`$regex`) | driver-sql | field, operator → nothing | field, operator → nothing | | unknown operator | driver-sql | field, operator → nothing | field, operator → nothing | | combinator, object operand | driver-sql | field, comparand → nothing | field, comparand → nothing | | combinator, primitive operand | driver-sql | comparand → nothing | comparand → nothing | | non-boolean `$null` | driver-sql | field, comparand → nothing | field, comparand → nothing | | non-boolean `$exists` | driver-sql | field, comparand → nothing | field, comparand → nothing | | text operator on a number field | engine | field → field (not changed) | no refusal: the door runs before the merge, and the scope matched 0 rows | | unreadable temporal comparand | engine | field, comparand → same (not changed) | no refusal, 0 rows | | unknown `{placeholder}` | `@objectstack/core` `resolveFilterTokens` via engine `resolveWhereTokens` | token → token (not changed) | no refusal: compared as a literal, 0 rows | - Every withheld row kept `INVALID_FILTER` / 400. The server log carried the field and the comparand. - On the CRUD face, a caller's own filter vouched `'author'` by the merge returned the same message before and after for all ten classes (first 220 characters compared). On the analytics face, the caller's own `where` answers were unchanged. - With the real `SecurityPlugin`, a CEL rule `policy_unsynced == 'PSECRET1u'` on a field that is declared but has no column: before, a 400 naming `policy_unsynced`; after, the unnamed wording. A CEL rule on an undeclared field answered 0 rows with no refusal, before and after. ## Host × mark: which marks a refusal door sees How each column was read: - **Driver columns:** measured with the real `SecurityPlugin` (a CEL `using` rule) over a real `ObjectQL` + `SqliteWasmDriver`, reading the `where` the driver received. - **Engine-doors column:** measured with a recording middleware registered first on the simulated-merge engine. The doors run before any middleware, and the real-`SecurityPlugin` rows were not recorded separately at that stage. - **Analytics row:** read from `withReadScope`, and consistent with the after-probe: the caller's arm discloses and the scope withholds. | host / path | engine doors see | driver: caller's `where` | driver: injected scope | |---|---|---|---| | no security plugin | no mark | unmarked | none | | `plugin-security`, member, plain `where` | no mark | `'author'` | `'policy'` | | `plugin-security`, member, no `where` | no mark | none | `'policy'` | | `plugin-security`, system context | no mark | unmarked | none | | `plugin-security`, anonymous | no mark | unmarked | none | | `plugin-security`, `where` holding `{current_user_id}` | no mark | unmarked (the engine rewrote it before the merge) | `'policy'` | | `plugin-security`, FilterArray `where` | no mark | `'author'` (the lowered copy is vouched) | `'policy'` | | analytics `withReadScope` | caller arm `'author'`, scope `'policy'` | `'author'` | `'policy'` | | Turso REMOTE mode through `TursoDriver` | not measured | unmarked: `toRemoteFilter` rebuilds every node, measured by an `'author'`-marked `where` getting the withheld text | unmarked | `plugin-sharing` was not measured end to end. **What an unmarked caller loses at the four driver doors:** its own field, operator, comparand and path. It gets the withheld wording with the same code and status. Measured cases: no security plugin, system context, anonymous, a `where` holding a placeholder token, and Turso remote mode. A member's plain `where` under `plugin-security` keeps the full text. ## The engine half: not in this PR `text-operator-declared-type-door.ts`, `temporal-comparand-door.ts` and the unknown-placeholder refusal (`UnknownFilterTokenError` from `packages/core/src/utils/filter-tokens.ts`, called by the engine's `resolveWhereTokens`) all run on the caller's `where` before the middleware chain. The first column of the host table is the measurement: on every host except the analytics face, the `where` these doors see carries no mark at all. Under "unmarked ⇒ withheld", every REST, SDK and flow caller would lose what these three doors name for its OWN filter: - the field and its declared type (the #15661 ruling asks for both); - the comparand and the remedy (#8690); - the placeholder and its "did you mean" suggestion. On the CRUD face a read scope never reaches these doors (measured above), so they disclose policy content only on the analytics face. Options and a recommendation are in the `os-dev-report` on #20020. The seat took option C in claim amendment 5823437510: the analytics-face pre-judge belongs to the services lane (pointer 5823452098 on #19995), and this PR does not change these three doors. ## Compile surfaces (per `references/compile-surfaces.md`) | # | face | this PR | |:--|:--|:--| | 1 | `driver-sql` `applyFilterCondition` (and `driver-sqlite-wasm` / Turso local, by inheritance) | CHANGED: four doors | | 2 | Turso `RemoteTransport` `buildWhereSQL` | CHANGED: three door classes. `pushLike` is untouched, and `unsupportedOperator`'s `$between` arm still names its target, which cannot be reached from a policy through `TursoDriver` | | 3 | service-analytics `compileScopedFilterToSql` | not touched (outside the claim); its refusals are the withheld `READ_SCOPE_COMPILE_FAILED` / 500 | | 4 | service-analytics `lowerAnalyticsWhere` | not touched (outside the claim); its non-boolean `$null` / `$exists` is #20040 | | 5 | `formula` `matchesFilterCondition` | not touched, not measured | | half | objectql `applyHaving` / `matchesHaving` | not touched, not measured | | — | `driver-memory` / `driver-mongodb` | not touched. They do not read the mark (grep); disclosure there is not measured | ## Tests - **New:** `packages/drivers/driver-sql/src/sql-driver-refusal-door-provenance.test.ts` (34 cases) and `packages/drivers/driver-turso/src/remote-transport-refusal-door-provenance.test.ts` (29 cases). Per door, they cover: - policy-marked: same code and status, and no secret in the message; the secrets are in the log or sink. - author-marked: the full text, equal to what the withheld case logged. - unmarked: byte-identical to policy. - both arm orders of a merged `$and`. - the column door's lookup by name, and the combinator door's node lookup under nesting and `$not`. - **Existing pins marked `'author'`:** these pins read a door's author-facing text on an unmarked `where`, so their `where` (or a shallow copy of a shared case constant) is now marked `'author'`, as a merge boundary marks a caller's own predicate. `driver-sql`: 11541, 17857, backend-fault-envelope, boolean-identity, filter-no-silent-drop, filter-refusal-envelope, icontains-and-retired-operators, null-operators (two assertions moved to code and status), out-of-contract-filter-input, text-case-conformance, unresolvable-where-column-refusal. `driver-turso`: bare-date-routing, comparand-refusal, node-operator-refusal, not-operator, null-comparand-refusal, text-predicates. `driver-sqlite-wasm`: icontains-and-retired-operators, which is outside the claim's file list, because `SqliteWasmDriver` inherits these doors. - **Rewritten:** `sql-driver-target-field-provenance.test.ts` "an UNDECLARED operator still names its operator and field". It stood so that this widening would be a deliberate act, and it now pins both halves. - **Turso remote mode:** `remote-transport-text-predicates.test.ts` (`$regex` through `TursoDriver` remote) and `turso-local-remote-text-parity.test.ts` now assert the withheld wording on the remote face, because `toRemoteFilter` drops the mark. A withheld remote answer must name none of the case's mentions. Suites at head `3bf7a68ce` (after merging `main`, which brought #20019): - `driver-sql`: 183 files passed, 11 skipped; 2771 tests passed, 170 skipped. - `driver-turso`: 65 files, 1473 tests passed. - `driver-sqlite-wasm`: 31 files, 582 tests passed. Before that merge, at `2e36f756d` (the same code as `8e3c49fbc`, which only adds the changeset): - `service-analytics`: 118 files, 2560 tests passed. - `plugin-security`: 130 files, 2561 tests passed. - `plugin-sharing`: 37 files, 913 tests passed. `typecheck` exits 0 for all three driver packages at `3bf7a68ce`. The two new pin files and the changed `driver-sqlite-wasm` file are in their packages' `tsc` programs (`--listFiles`, 1 each). **Consumer pin changed (patch round 1, `244f5321e`): `packages/plugins/plugin-auth/src/auth-contains-filter.test.ts`, face 3 (#5893).** It read the retired-operator door's full text through an unmarked `engine.find` on a real `SqlDriver`, and it went red at `b6f40dc95` (CI Test Core 6/6). It is re-pinned to the production behaviour: - **Unmarked leg**, renamed "… and withholds the operator from an unmarked caller": `INVALID_FILTER` / 400, the message contains "is RETIRED" and names neither `$regex` nor `$icontains`, and the spied driver log names both. - **New `'author'`-marked leg:** the full text, `$regex` and the `$icontains` prescription, is kept for a vouched caller. - **The file's header note** is restated to match. No plugin-auth source changed. A repo-wide sweep found no other consumer pin on these four doors' wording. The old-wording grep over `packages/`, `examples/` and `apps/` tests gave 101 files, 19 of which reference a SQL-family driver, each read at its hit line. A trigger-token grep over the 311 test files outside the three drivers that use a real SQL driver found nothing more. Patch-round suites (`244f5321e`, the same code as `5fb4b25b1`, which only edits the changeset), each with its own exit code 0: - `plugin-auth`: 114 files, 2440 tests passed. - `service-storage`: 40 files, 627 tests passed. - `cli` unit project: 224 files; 3129 tests passed, 29 skipped. Two published-subpath pin files needed `cli` built first, and passed after the build. - `cloud-connection` 29/374, `plugin-dev` 8/80, `connector-mcp` 3/23 and `hono` 5/122. - At `5fb4b25b1`: `driver-sql`, `driver-turso` and `driver-sqlite-wasm` give the same counts as above. These are the rest of CI Test Core shard 6/6, which may not have run once plugin-auth failed, because the step runs turbo without `--continue`. `example-embed-objectql` is NOT MEASURED: it depends only on driver-memory, objectql and spec. **Changeset (patch round 1, `5fb4b25b1`):** the frontmatter gains `'@objectstack/driver-sqlite-wasm': patch`, and one sentence states the inheritance: "`SqliteWasmDriver` (`@objectstack/driver-sqlite-wasm`) and `TursoDriver` in local mode extend `SqlDriver`, so they inherit this change from it: the same four doors answer the same way there." **Ablations** (committed state, restored and proved by blob hash plus an empty `git diff HEAD`): 1. `sql-driver.ts` reverted to base: the new driver-sql pin file had 32 of 34 failing. The 2 that passed are author-disclosure cases that base satisfies by disclosing to everyone. 2. The combinator door's node lookup replaced with `undefined` (through `scripts/ablation-replace.mjs`, anchor 1 → 0): 4 failing, exactly the primitive-operand author cases. 3. `remote-transport.ts` reverted to base: 29 of 29 failing. ## Gates - `dispatch-gates --commands` derived 62 families from this diff at `3bf7a68ce`. All 62 exited 0. `check:dual-build-cjs-loads` and `check:type-check-debt` first exited 3 (PREREQUISITE NOT MET) and exited 0 after `turbo run build --filter=./packages/* --filter=./packages/*/*`. `--ran` reconciliation: 62 derived, 62 run, 0 NOT-MEASURED, derived from recorded exit codes. - After the last changeset edit (`b6f40dc95`), the changeset and prose gates were re-run and exited 0: ADR-0087 registration, no-major, empty-changeset, deadline census, objectui-changeset, nul-bytes, doc-authoring. - `check-issue-citations --base 9d81af7`: exit 0, 28 citations resolve. - `check:driver-conformance`: "50 covered cell(s), 0 in the DEBT ledger, 0 exempt" before and after. - ESLint on the 24 changed `.ts` files: 24 files, 0 errors, 0 warnings. `eslint.config.mjs` sets no `parserOptions.project`, so this diff cannot change a verdict on an untouched file. The repo-wide run is CI's. ## Acceptance notes - **Eight more `driver-sql` doors relay policy content on the CRUD face.** Measured with the same probe and a policy-marked scope, at head: - an empty `$icontains`; - a non-string `$like`; - a `$like` pattern with a trailing escape; - an object `$contains` comparand; - an object `$in` member; - an `undefined` comparand; - a non-node element of `$or`; - an undeclared node combinator. Each 400 named the field or the comparand. They are outside this card's seven and the claim's file surface, so they are reported to the seat as a finding rather than fixed here. The seat filed them as #20039. - **Analytics caller `where`.** On `POST /api/v1/analytics/query`, a caller's `{ f: { $null: 'x' } }` or `{ f: { $exists: 'x' } }` answered 200. The driver received `{ f: { $ne: null } }`. Reported to the seat, which filed it as #20040 (services lane). - **Stale prose, not pins:** two test comments still say `driver-sql` refuses `$regex` "by name", which is now true only for an `'author'`-marked `where`: plugin-auth `auth-where-operator-coverage.test.ts` (header note) and runtime `undeclared-field-write-driver-split.integration.test.ts`. Neither asserts that wording. - **Turso remote mode.** `TursoDriver.toRemoteFilter` rebuilds every filter node, so no mark reaches `RemoteTransport` in remote mode, and every mark-reading refusal there withholds for every caller. This fails closed and discloses nothing. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7465eeb commit 57c2b73

26 files changed

Lines changed: 1096 additions & 126 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
'@objectstack/driver-sqlite-wasm': patch
4+
'@objectstack/driver-turso': patch
5+
---
6+
7+
fix(driver-sql, driver-turso): four filter-refusal doors stop naming a read scope's field and comparand unless the refused predicate is marked as the caller's own (#20020)
8+
9+
Clause-②: no
10+
11+
A read scope is the RLS, sharing or tenant predicate that `plugin-security` (ordinary reads) and `service-analytics` (the ObjectQL analytics face) AND into the caller's `where`. Both merges mark the scope `'policy'` and the caller's own predicate `'author'` (`markFilterSubtreeProvenance`, `@objectstack/spec/data`). When `SqlDriver` refused a scope at one of the four doors below, the `INVALID_FILTER` / 400 message named the scope's field, and for three of them its comparand too. It did not check the mark. Measured on both faces, through `POST /api/v1/analytics/query` and through an ObjectQL `find` under a merge shaped like `plugin-security`'s:
12+
13+
- a column the table does not have. This is reachable from a real CEL rule on a field that is declared but has no column yet;
14+
- a retired operator (`$regex`, `$options`) or an operator outside the vocabulary;
15+
- `$and` / `$or` whose operand is not a list;
16+
- a `$null` / `$exists` whose comparand is not a boolean.
17+
18+
Each of these doors now reads the mark on the node it refused, the same way the cross-field and target-field refusals already did:
19+
20+
- **`'policy'`, unmarked or ambiguous:** same `INVALID_FILTER` / 400. The message says which kind of refusal fired, but names no field, operator, comparand or filter path. Those go to the server log. For the unresolvable column, the message is the unnamed wording the driver already used when it could not parse the dialect's message.
21+
- **`'author'`:** the full message, the same text the door answered before.
22+
23+
To find the node, the unresolvable-column door looks up the column name the database reported. It discloses only when every node that names that column is marked `'author'`. A `$and` / `$or` with a primitive operand is judged by the node that carries the key.
24+
25+
`SqliteWasmDriver` (`@objectstack/driver-sqlite-wasm`) and `TursoDriver` in local mode extend `SqlDriver`, so they inherit this change from it: the same four doors answer the same way there.
26+
27+
**What an unmarked caller loses:** its own diagnostic from these four doors. Measured cases where the caller's own predicate reaches the driver unmarked:
28+
29+
- no security plugin in the stack;
30+
- a system-context call;
31+
- an anonymous call;
32+
- a `where` that holds a `{placeholder}` token, which the engine rewrites before the merge.
33+
34+
That caller gets the withheld wording with the same code and status. A member's plain `where` under `plugin-security` is marked `'author'` and keeps the full text.
35+
36+
The same three door classes on the Turso REMOTE transport (`RemoteTransport`) now read the mark too: the retired or unknown operator (including a non-operator key in an operator map), the non-list combinator, and the non-boolean `$null` / `$exists`. The operands go to its diagnostic sink. `TursoDriver`'s remote mode rebuilds every filter node before the transport sees it, so no mark reaches the transport there, and these refusals keep the withheld wording for every caller in that mode. The unresolvable WHERE column has no refusal on the remote face (the transport answers `[]`) and is not changed here.
37+
38+
Not changed: which filters are refused, and the code and status of every refusal. The engine's declared-type, temporal-comparand and filter-token doors are not changed here.

‎packages/drivers/driver-sql/src/sql-driver-11541-aggregate-unresolvable-column-refusal.test.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262

6363
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
6464
import type { DriverQuery } from '@objectstack/spec/contracts';
65+
import { markFilterSubtreeProvenance } from '@objectstack/spec/data';
6566
import { SqlDriver } from './sql-driver.js';
6667
import { DIALECT_CELLS, declareDialectCell, dialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
6768

@@ -140,7 +141,15 @@ describe(`[#11541] driver-sql — aggregate() attributes an unresolvable column
140141
// THE PROBE TABLE — five rows, three doors, one class of answer
141142
// ───────────────────────────────────────────────────────────────
142143

143-
const WHERE_NOSUCHCOL: NonNullable<DriverQuery['where']> = { nosuchcol: SECRET_LITERAL };
144+
// [#20020] Marked 'author', as a read-scope merge boundary marks a caller's
145+
// own predicate: the column NAME reaches the wire only for a predicate the
146+
// caller is known to have written (the #8220 contract), and every row below
147+
// pins that author-facing answer. The unmarked and policy-marked answers are
148+
// pinned in `sql-driver-refusal-door-provenance.test.ts`.
149+
const WHERE_NOSUCHCOL: NonNullable<DriverQuery['where']> = markFilterSubtreeProvenance(
150+
{ nosuchcol: SECRET_LITERAL },
151+
'author',
152+
);
144153

145154
it('rows 1-2: find() and count() still answer the #8790 refusal — the baseline this door joins', async () => {
146155
for (const [half, run] of [
@@ -383,7 +392,8 @@ describe('[#11541] aggregateBackendFault — the three arms and their fences', (
383392

384393
it('arm 2: a name in neither clause is the WHERE — #8790 verbatim', () => {
385394
const err = classify(
386-
{ where: { nosuchcol: 1 }, aggregations: [{ function: 'count', alias: 'n' }] },
395+
// [#20020] The caller's own predicate, marked as a merge boundary marks it.
396+
{ where: markFilterSubtreeProvenance({ nosuchcol: 1 }, 'author'), aggregations: [{ function: 'count', alias: 'n' }] },
387397
'select count(*) as `n` from `t` where `nosuchcol` = 1 - no such column: nosuchcol',
388398
);
389399
expect(err.code).toBe('INVALID_FILTER');
@@ -413,7 +423,7 @@ describe('[#11541] aggregateBackendFault — the three arms and their fences', (
413423
// MySQL (DOTTED_STATUS_QUO, #8371 owns the dotted-path verdict).
414424
it('FENCE: a dotted where key is not attributed to an aggregation over its tail segment', () => {
415425
const err = classify(
416-
{ where: { 'title.x': 1 }, aggregations: [{ function: 'avg', field: 'x', alias: 'n' }] },
426+
{ where: markFilterSubtreeProvenance({ 'title.x': 1 }, 'author'), aggregations: [{ function: 'avg', field: 'x', alias: 'n' }] },
417427
'select avg(`x`) as `n` from `t` where `title`.`x` = 1 - no such column: title.x',
418428
);
419429
expect(err.code).toBe('INVALID_FILTER');

‎packages/drivers/driver-sql/src/sql-driver-17857-distinct-unresolvable-column-refusal.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
8484
import type { DriverQuery } from '@objectstack/spec/contracts';
8585
import type { FilterCondition } from '@objectstack/spec/data';
86+
import { markFilterSubtreeProvenance } from '@objectstack/spec/data';
8687
import { SqlDriver } from './sql-driver.js';
8788
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
8889

@@ -182,7 +183,13 @@ describe(`[#17857] driver-sql — distinct() attributes an unresolvable column (
182183
const onCount = await caught(() => driver.count(TABLE, { where: { [MISSING_COLUMN]: 1 } }));
183184
const onFind = await caught(() => driver.find(TABLE, { where: { [MISSING_COLUMN]: 1 } }));
184185
const onAggregate = await caught(() => driver.aggregate(TABLE, GROUP_BY_MISSING));
185-
const onDistinctFilter = await caught(() => driver.distinct(TABLE, 'title', { [MISSING_COLUMN]: 1 }));
186+
// [#20020] The caller's own filter, marked as a read-scope merge boundary
187+
// marks it: the column NAME reaches the wire only for a predicate the
188+
// caller is known to have written (the #8220 contract). The unmarked and
189+
// policy answers are pinned in `sql-driver-refusal-door-provenance.test.ts`.
190+
const onDistinctFilter = await caught(() =>
191+
driver.distinct(TABLE, 'title', markFilterSubtreeProvenance({ [MISSING_COLUMN]: 1 }, 'author')),
192+
);
186193
const onDistinctField = await caught(() => driver.distinct(TABLE, MISSING_COLUMN));
187194

188195
// Rows 1-3: the ruled answers, unmoved by this card.

‎packages/drivers/driver-sql/src/sql-driver-backend-fault-envelope.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686

8787
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
8888
import type { DriverQuery } from '@objectstack/spec/contracts';
89+
import { markFilterSubtreeProvenance } from '@objectstack/spec/data';
8990
import { SqlDriver } from './sql-driver.js';
9091
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
9192

@@ -110,7 +111,13 @@ const SECRET_LITERAL = 'zz-backend-fault-must-not-leak';
110111
*/
111112
const DOTTED_KEY: NonNullable<DriverQuery['where']> = { 'title.x': SECRET_LITERAL };
112113
const TYPE_REJECTED: NonNullable<DriverQuery['where']> = { rank: SECRET_LITERAL };
113-
const UNRESOLVABLE_COLUMN: NonNullable<DriverQuery['where']> = { nosuchcol: SECRET_LITERAL };
114+
// [#20020] Marked 'author', as a read-scope merge boundary marks a caller's own
115+
// predicate: the #8790 refusal names the column only for a predicate the caller
116+
// is known to have written (the #8220 contract).
117+
const UNRESOLVABLE_COLUMN: NonNullable<DriverQuery['where']> = markFilterSubtreeProvenance(
118+
{ nosuchcol: SECRET_LITERAL },
119+
'author',
120+
);
114121

115122
async function caught(run: () => Promise<unknown>): Promise<any> {
116123
try {

‎packages/drivers/driver-sql/src/sql-driver-boolean-identity.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5555
import { SqlDriver } from '../src/index.js';
5656
import type { FilterCondition } from '@objectstack/spec/data';
57+
import { markFilterSubtreeProvenance } from '@objectstack/spec/data';
5758

5859
const FIXTURE = [
5960
{ id: '1', stage: 'won', owner: 'u1', amount: 10 },
@@ -199,7 +200,11 @@ describe('[#5134] SqlDriver compiles empty $and/$or/$not to their boolean identi
199200

200201
for (const [name, where, position] of cases) {
201202
it(`${name} → 400 INVALID_FILTER naming ${position}`, async () => {
202-
const err = await refusalOf(where);
203+
// [#20020] Marked 'author', as a read-scope merge boundary marks a
204+
// caller's own predicate: the non-list combinator refusal names its
205+
// position only for a predicate the caller is known to have written
206+
// (the #8220 contract); the other rows name it either way.
207+
const err = await refusalOf(markFilterSubtreeProvenance(where, 'author'));
203208
expect(err.code).toBe('INVALID_FILTER');
204209
expect(err.status).toBe(400);
205210
expect(err.message).toContain(position);

‎packages/drivers/driver-sql/src/sql-driver-filter-no-silent-drop.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242

4343
import { describe, it, expect, beforeEach } from 'vitest';
4444
import { SqlDriver } from '../src/index.js';
45-
import { parseFilterAST, type FilterCondition } from '@objectstack/spec/data';
45+
import { markFilterSubtreeProvenance, parseFilterAST, type FilterCondition } from '@objectstack/spec/data';
4646

4747
describe('SqlDriver rejects an uncompilable filter instead of dropping it', () => {
4848
let driver: SqlDriver;
@@ -134,7 +134,10 @@ describe('SqlDriver rejects an uncompilable filter instead of dropping it', () =
134134
it('still throws on an unsupported operator inside a well-formed condition', async () => {
135135
// Pre-existing behaviour on the shape the driver still compiles, pinned so
136136
// the refusal cannot regress into a silent drop one layer down.
137-
await expect(find({ stage: { $sounds_like: 'won' } }))
137+
// [#20020] Marked 'author', as a read-scope merge boundary marks a caller's
138+
// own predicate: the operator and field are named only then (the #8220
139+
// contract). Unmarked, the refusal is the same code, withheld.
140+
await expect(find(markFilterSubtreeProvenance({ stage: { $sounds_like: 'won' } }, 'author')))
138141
.rejects.toThrow(/Unsupported filter operator "\$sounds_like"/);
139142
});
140143

‎packages/drivers/driver-sql/src/sql-driver-filter-refusal-envelope.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import { describe, it, expect, beforeEach } from 'vitest';
3232
import { SqlDriver } from '../src/index.js';
3333
import type { FilterCondition } from '@objectstack/spec/data';
34+
import { markFilterSubtreeProvenance } from '@objectstack/spec/data';
3435

3536
/** The shape `mapDataError` / `sendError` read off a thrown driver error. */
3637
interface WireBearingError extends Error {
@@ -74,7 +75,13 @@ describe('[#4436] SqlDriver filter refusals carry INVALID_FILTER and leak no dri
7475

7576
// The issue's own repro, at the layer that produces the envelope.
7677
it('the $-object unsupported-operator branch — the exact shape #4436 reported', async () => {
77-
const err = await refusalOf(() => find({ stage: { $bogusop: 'x' } }));
78+
// [#20020] Marked 'author', as a read-scope merge boundary marks a caller's
79+
// own predicate: WHICH operator on WHICH field is the predicate's detail,
80+
// named only for a predicate the caller is known to have written (the
81+
// #8220 contract). The vocabulary is a capability statement and survives
82+
// either way — pinned for the withheld wording in
83+
// `sql-driver-refusal-door-provenance.test.ts`.
84+
const err = await refusalOf(() => find(markFilterSubtreeProvenance({ stage: { $bogusop: 'x' } }, 'author')));
7885
expect(err.code).toBe('INVALID_FILTER');
7986
expect(err.status).toBe(400);
8087
expect(err.message).not.toContain('[sql-driver]');

‎packages/drivers/driver-sql/src/sql-driver-icontains-and-retired-operators.test.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
import type { Knex } from 'knex';
8080
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
8181
import type { DriverOptions, FilterCondition } from '@objectstack/spec/data';
82-
import { FILTER_TEXT_CASES, FILTER_TEXT_ROWS } from '@objectstack/spec/data';
82+
import { FILTER_TEXT_CASES, FILTER_TEXT_ROWS, markFilterSubtreeProvenance } from '@objectstack/spec/data';
8383
import { SqlDriver } from './sql-driver.js';
8484
import { dialectCell } from './live-dialect-matrix.testkit.js';
8585

@@ -141,6 +141,16 @@ describe('[#5702] SqlDriver — $icontains, and the retired $regex/$options', ()
141141
return err;
142142
};
143143

144+
/**
145+
* [#20020] The caller's own predicate, marked as a read-scope merge boundary
146+
* marks it — on a shallow COPY, so a shared case constant is never marked.
147+
* The retired-operator refusal names the operator and its replacement only
148+
* for a predicate the caller is known to have written (the #8220 contract);
149+
* the withheld wording is pinned in `sql-driver-refusal-door-provenance.test.ts`.
150+
*/
151+
const authored = (where: FilterCondition): FilterCondition =>
152+
markFilterSubtreeProvenance({ ...where }, 'author');
153+
144154
it('seeded the fixture (the premise)', async () => {
145155
expect(await ids({})).toEqual(['1', '2', '3', '4', '5', '6', '7', '8', '9']);
146156
});
@@ -232,7 +242,7 @@ describe('[#5702] SqlDriver — $icontains, and the retired $regex/$options', ()
232242
],
233243
] as const) {
234244
it(`REFUSES ${label}, naming the replacement`, async () => {
235-
const err = await refusalOf(where);
245+
const err = await refusalOf(authored(where));
236246
expect(err.code).toBe('INVALID_FILTER');
237247
expect(err.status).toBe(400);
238248
expect(err.message).toContain('RETIRED');
@@ -341,7 +351,7 @@ describe('[#5702] SqlDriver — $icontains, and the retired $regex/$options', ()
341351
for (const testCase of FILTER_TEXT_CASES) {
342352
it(testCase.name, async () => {
343353
if (testCase.expectRejection) {
344-
const err = await refusalOf(testCase.filter);
354+
const err = await refusalOf(authored(testCase.filter));
345355
expect(err.code).toBe(testCase.code);
346356
expect(err.status).toBe(400);
347357
for (const mention of testCase.mustMention) expect(err.message).toContain(mention);

‎packages/drivers/driver-sql/src/sql-driver-null-operators.test.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4-
import { parseFilterAST, type FilterCondition } from '@objectstack/spec/data';
4+
import { markFilterSubtreeProvenance, parseFilterAST, type FilterCondition } from '@objectstack/spec/data';
55
import { SqlDriver } from '../src/index.js';
66

77
/**
@@ -78,8 +78,12 @@ describe('SqlDriver — null / empty operators (#2704)', () => {
7878
// ENGINE door gates on `isFilterAST` first and refuses this a layer
7979
// earlier (engine-filter-array-lowering.test.ts). Either way it throws;
8080
// what #2704 forbids is the whole-table answer.
81+
// [#20020] The refusal is the INVALID_FILTER identity whatever the
82+
// predicate's provenance; its wording names the operator only for a
83+
// predicate marked 'author' (the #8220 contract), which this lowered,
84+
// unmarked one is not — so the assertion is on the identity.
8185
await expect(lowered([['assignee', 'totally_bogus', null]]))
82-
.rejects.toThrow(/Unsupported filter operator/);
86+
.rejects.toMatchObject({ code: 'INVALID_FILTER', status: 400 });
8387
});
8488

8589
it('count with is_null is scoped, not the whole table', async () => {
@@ -134,8 +138,11 @@ describe('SqlDriver — null / empty operators (#2704)', () => {
134138
});
135139

136140
it('$regex is REFUSED, in the ADR-0112 envelope, naming $icontains', async () => {
141+
// [#20020] Marked 'author', as a read-scope merge boundary marks a
142+
// caller's own predicate: the operator and its replacement are named
143+
// only then (the #8220 contract).
137144
const err = await driver
138-
.find('tasks', { where: { assignee: { $regex: 'aro' } } })
145+
.find('tasks', { where: markFilterSubtreeProvenance({ assignee: { $regex: 'aro' } }, 'author') })
139146
.then(() => null, (e: any) => e);
140147
expect(err).toBeInstanceOf(Error);
141148
expect(err.code).toBe('INVALID_FILTER');
@@ -161,9 +168,10 @@ describe('SqlDriver — null / empty operators (#2704)', () => {
161168
});
162169

163170
it('unknown $-operator throws instead of a silent equality compare', async () => {
171+
// [#20020] Identity, not wording — see the lowered-array case above.
164172
await expect(
165173
driver.find('tasks', { where: { assignee: { $bogus: 1 } } }),
166-
).rejects.toThrow(/Unsupported filter operator/);
174+
).rejects.toMatchObject({ code: 'INVALID_FILTER', status: 400 });
167175
});
168176
});
169177
});

‎packages/drivers/driver-sql/src/sql-driver-out-of-contract-filter-input.test.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
import { describe, it, expect, beforeEach } from 'vitest';
5959
import { SqlDriver } from './index.js';
6060
import type { FilterCondition } from '@objectstack/spec/data';
61+
import { markFilterSubtreeProvenance } from '@objectstack/spec/data';
6162

6263
interface WireBearingError extends Error {
6364
code?: string;
@@ -185,7 +186,11 @@ describe('[#5347/#5348] SqlDriver refuses out-of-contract filter input', () => {
185186

186187
for (const [label, value] of NON_BOOLEAN) {
187188
it(`refuses ${label} with INVALID_FILTER / 400`, async () => {
188-
const err = await refusalOf({ stage: { $null: value } });
189+
// [#20020] Marked 'author', as a read-scope merge boundary marks a
190+
// caller's own predicate: the field, the value and the path are named
191+
// only then (the #8220 contract). The withheld wording is pinned in
192+
// `sql-driver-refusal-door-provenance.test.ts`.
193+
const err = await refusalOf(markFilterSubtreeProvenance({ stage: { $null: value } }, 'author'));
189194
expect(err.code).toBe('INVALID_FILTER');
190195
expect(err.status).toBe(400);
191196
expect(err.message).toContain('Operator "$null" on field "stage" requires a boolean comparand');
@@ -253,7 +258,11 @@ describe('[#5347/#5348] SqlDriver refuses out-of-contract filter input', () => {
253258

254259
for (const [label, value] of NON_BOOLEAN) {
255260
it(`refuses ${label} with INVALID_FILTER / 400`, async () => {
256-
const err = await refusalOf({ stage: { $exists: value } });
261+
// [#20020] Marked 'author', as a read-scope merge boundary marks a
262+
// caller's own predicate: the field, the value and the path are named
263+
// only then (the #8220 contract). The withheld wording is pinned in
264+
// `sql-driver-refusal-door-provenance.test.ts`.
265+
const err = await refusalOf(markFilterSubtreeProvenance({ stage: { $exists: value } }, 'author'));
257266
expect(err.code).toBe('INVALID_FILTER');
258267
expect(err.status).toBe(400);
259268
expect(err.message).toContain('Operator "$exists" on field "stage" requires a boolean comparand');

0 commit comments

Comments
 (0)