Commit ca5408c
Part of #20802
Clause-②: yes (widening)
The engine half of ruling 5907789183 (letter A). The analytics cube read
and the analytics read-scope face are the second half, after #5930 step
3 (#20810); `skills/objectstack-query` (#20782) belongs to the skills
seat, who is told after this lands. So this PR does not complete the
card.
## What this does
**The nested-relation form `{ relation: { field: value } }` is served in
`where`.** It is lowered at the engine's filter seam that #5930 step 2
built (`cfa931535`). The drivers receive `$in` / `$contains` and are not
changed (ADR-0053 D-D1 item 5, D4 (b)).
- **Where the lowering runs.** Stage 2 of `where` admission now has
three steps: resolve the placeholders, then lower each nested-relation
condition, then run the shared `lowerFilterCondition`.
`ObjectQL.resolveRelateThenLowerWhere` holds that order, so a verb
cannot resolve without it. `resolveWhereTokens` and `withResolvedWhere`
became async to carry it.
- **How the lowering works.** `lowerRelationConditions` reads the
related object with the engine's own `find`: `fields: ['id']`, `limit:
RELATION_FILTER_ID_CAP + 1`, and the caller's execution context. The ids
it returns become `{ relation: { $in: ids } }` on a single-valued
relation. On a multi-valued one they become an `$or` of one `$contains`
per id, which matches on any member. That is the spec's own any-of
spelling, and the SQL family refuses `$in` over the JSON column. No
related record matching gives `$in: []` or `$or: []`: FALSE, never an
absent predicate.
- **No second walk.** A condition is found by the one filter walk the
engine already runs with each column's declaration in hand,
`walkCondition` in `number-comparand-declared-type-door.ts`.
- At the door (stage 1), that walk admits a condition structurally, from
declarations alone (`admitRelationCondition`), instead of refusing it.
- The lowering calls the same walk (`mapRelationConditions`) twice: once
to collect the conditions, and once, after the reads, to replace them in
the same order.
- The door and the lowering therefore find a condition at the same
boundaries by construction. No driver-local guard exists.
- **As the caller.** The related read goes through the middleware chain
like any read. The related object's CRUD gate, row scope and field
permissions apply, and its own doors judge the condition's comparands. A
refusal from any of them is the answer, loudly.
- **Bounded.** The cap is `RELATION_FILTER_ID_CAP` = 1000, one named
constant, exported from `@objectstack/objectql`. Past it the filter is
refused with `INVALID_FILTER` / 400. The refusal names the cap, the
related object and the two-step route. The filter is never run over a
cut-off list.
**The accept set that widens.** It is the engine's `where` (`find`,
`findOne`, `count`, `aggregate`, `update`, `delete`) plus `judgeFilter`,
and the REST query doors that reach `findData` (`POST
/api/v1/data/:object/query` and the `filter` / `$filter` spellings). Per
relation kind:
- `lookup`, `master_detail`, `user`, `tree`, single-valued: refused, now
served (`$in`).
- the same kinds with `multiple: true`: refused, now served (any
member).
**Nothing served today narrows.**
- The admission runs only where the arm refused before: a no-operator
object beneath a relation column at `where`.
- The cap refusal is new, but it applies to a form that was refused.
- Every filter without a nested-relation condition reaches the drivers
by reference, byte-identical to before (pinned).
**What stays refused.** Each is refused in the engine's words, before
any read:
- a `json` field's object comparand, and the provisioned `id`
(unchanged);
- a second level (a relation condition beneath the related object's own
relation field);
- a dotted key inside the condition;
- a key the related object does not declare;
- `{}`;
- a related object that is not registered;
- the dotted path `{ 'owner.region': 'NA' }`, still `INVALID_FIELD`
(#8371);
- the form in an aggregation's own `filter` and in `having`. The engine
evaluates both itself, and its evaluator has no member test for a stored
list. Their words now say `where` serves it.
**Text.**
- `FilterCondition`'s docblock item 4 now states the served semantics.
- The `QueryFilter` example shows the form again.
- The `data-engine.mdx` example that PR #20781 removed comes back, with
the cut, the cap and the two-step route.
**The dotted-path words, made true again (a bounded in-place fix, named
here).**
- Both dotted relation refusals said "a filter reaches only columns of
'…' itself". This change makes that false.
- The two refusals are the engine's (`filter-comparand-shape.ts`) and
the query-parameter door's (`metadata-protocol` `protocol.ts`, outside
the claim's declared file surface).
- Both now name the nested form to write instead (`{ "owner": {
"region": VALUE } }`), in the same words, and keep the shared
denormalise remedy.
- A conformance pin holds the two routes equal.
## Measured
On this branch at `56da9b6d50` through `POST
/api/v1/data/:object/query`. Owner `u1` is region NA on `d1` and `d3`,
and `d4` has no owner. Before, on `origin/main` after PR #20781, every
relation row answered `INVALID_FILTER` / 400 on every driver.
| `where` | SQLite | PostgreSQL 16.13 (live, local) | InMemoryDriver
(measured, not pinned) |
|:--|:--|:--|:--|
| `{ owner: { region: 'NA' } }` (lookup), `boss` (master_detail) | `d1`,
`d3` | `d1`, `d3` | `d1`, `d3` |
| `{ owners: { region: 'NA' } }` (multiple lookup) | `d1`, `d3` | `d1`,
`d3` | `d1`, `d3` (see the note) |
| `{ parent: { title: 'a' } }` (tree) | `d2`, `d3` | `d2`, `d3` | `d2`,
`d3` |
| `{ $not: { owner: { region: 'NA' } } }` | `d2`, `d4` | `d2`, `d4` |
`d2`, `d4` |
| `{ $or: [{ owner: { region: 'EU' } }, { title: 'a' }] }` | `d1`, `d2`
| `d1`, `d2` | `d1`, `d2` |
| `{ owner: { region: 'APAC' } }` (no match) | none | none | none |
| a condition matching 1001 related records | 400 `INVALID_FILTER`, the
cap words | same | not measured |
- **The memory note.** The in-memory driver matches `$contains` over a
stored array by substring per element. That is the gap
`FILTER_OPERATORS`' `$contains` docblock records for that driver. So
with ids `u1` and `u10`, a multi-valued condition meaning `u1` also
matches the row holding `['u10']`: measured memory `d1`, `d3`, `d5`,
against SQL `d1`, `d3`. Single-valued relations are exact everywhere.
- **Why memory is not pinned.** `check:driver-memory-census` refuses a
new test consumer of that driver without a ruling.
- **H7 (RLS), measured.** A policy `record.owner.region == 'NA'` is
refused at compile: "cross-object/nested field path … is not
pushdown-able". The policy is dropped to the deny sentinel, so it
answers zero rows. The RLS compile seam is untouched, and no async read
was added there.
## Mechanism hypotheses: which held
- **H1: held, refined.** `lowerFilterCondition` is pure and synchronous,
and the relation step needs the engine. So the step lives in objectql,
between token resolution and the shared lowering, and each engine filter
position reaches it at most once.
- Served: `where` on `find`, `findOne`, `count`, `aggregate`, `update`
and `delete` (the multi and by-predicate paths alike), and the judge.
- Not served (refused, named): `aggregations[i].filter` and `having`.
- **H2: held, with one widening of the kind list.** The door admits the
form under every `REFERENCE_VALUE_TYPES` kind, not only `lookup` /
`master_detail`.
- Reason: `user` and `tree` point at a related object the same way, and
the #20745 seat answer ruled that one class gets one answer.
- `user` needs `sys_user` registered; where it is not, it is refused
loudly ("no object 'sys_user' is registered here").
- Every refusal the order listed is kept, as listed above.
- **H3: measured "yes": the one check exists and is reused.**
- A direct filter on a field the caller cannot read is refused today:
`403 PERMISSION_DENIED`, the security layer's filter-oracle guard
`assertReadableQueryFields`.
- The related read reaches that same check, so the nested form answers
the same 403, naming the field.
- There is no second copy of the rule. ⚠️ This is not the ruling's
literal `INVALID_FILTER`; see the open question in the report.
- **H4: `$in` does not mean "any member" everywhere; `$or` of
`$contains` does on SQL.**
- `$in` over a multi-valued lookup is refused on SQL (JSON column) and
is any-member on memory.
- `$contains` is membership on SQLite and PostgreSQL, and
substring-per-element on memory (the note above).
- The `$or` of `$contains` is the spec's declared any-of spelling, so it
is the lowered form.
- **H5: held, and no bound to reuse.** `expand`'s batch loader bounds
nothing: it deliberately forwards no limit. The cap is a new named
constant.
- **H6: held.** `$and` / `$or` compose as written. `$not` over a
relation condition takes the shared lowering's NULL-safe negation, so a
row with no relation satisfies it. The driver input is pinned equal to
the hand-written two-step route's, and `$not` + no match gives every
row. An empty inner result is FALSE, never "no filter". `$nor` is not in
the vocabulary.
- **H7: held; out of scope.** Measured above.
## Tests (all on `56da9b6d50`)
- `@objectstack/objectql` test: 345 files / 6786 passed. typecheck exit
0, `check:test-typecheck` OK.
- `@objectstack/rest` test, with `OS_TEST_POSTGRES_URL` set to a local
PostgreSQL 16.13: 237 files / 4706 passed / 35 skipped (MySQL cells and
suites with no URL). typecheck exit 0.
- `@objectstack/metadata-protocol` test: 191 files passed, 3 skipped /
2801 passed, 19 skipped. typecheck exit 0.
- `@objectstack/spec` test: 578 files / 17066 passed / 1 todo. typecheck
exit 0. `check:generated`: all 15 artifacts up to date (no regeneration
needed; docblock only).
- Downstream: `@objectstack/plugin-security` 149 files / 3227 passed, 23
skipped. `driver-memory` 65 / 1470 passed. `driver-sql` 201 files
passed, 11 skipped / 3254 passed, 188 skipped. The other
`...@objectstack/objectql` consumers are declared to CI.
- New pins:
- `packages/objectql/src/engine-nested-relation-lowering.test.ts` (13
tests, recording driver). It covers:
- every relation type;
- any-member on a multi-valued relation;
- the empty id set;
- `$and` / `$or` / `$not` / sugar, pinned equal to the two-step route's
driver input;
- every verb and the judge;
- placeholder resolution;
- the related read as the caller, and a middleware refusal surfacing
with no outer read;
- the cap at 1000 and at 1001;
- the kept refusals, with the judge answering execution's words
verbatim;
- the related object's own doors;
- the aggregation `filter` / `having` refusals;
- a pass-through control.
- `packages/rest/src/data-nested-object-door.test.ts` (rewritten):
#20745's table turned into rows on SQLite and live PostgreSQL, plus the
two-step equivalence, the kept refusals with the route inside the
500-character REST bound, the cap pin (1001 related records refused,
1000 served) and the controls.
- `packages/rest/src/data-nested-relation-permission.test.ts`: the
permission pin, with the real `SecurityPlugin` on a real engine and
SQLite, through the REST door. An unreadable related field is refused
403, never emptied, while a system read can filter by it. A hidden
related record matches nothing.
- Fixture triage for the removed refusal branch:
- `engine-nested-object-door.test.ts` keeps only what still refuses.
- `query-expression-conformance.test.ts`: its nested-form control now
pins the served rows, and a new pin checks that both doors' dotted
refusals name the same route.
- `protocol-explicit-filter-field-gate.test.ts`: its GUARD still proves
the name gate never descends.
**Ablations, each from the committed fix.** Each ran through
`scripts/ablation-replace.mjs` in WRAP mode, trap-restored. After each
mutation objectql was rebuilt, and `ablation-dist-preflight` found the
marker in 4 built files.
- **A, the lowering disabled.** `if (sites.length === 0) return where;`
became an unconditional `return where` (marker
`__ablated_20802_lowering__`). Blob `9237c3dfc995` → `f9994599356f`.
Predicted red, observed red:
- objectql pins: 10 failed / 235 passed;
- rest pins: 11 failed / 4 passed / 6 skipped.
- The structural refusals and controls stayed green.
- **B, the related read as the system.** `...(execCtx ? { context:
execCtx } : {}),` became `isSystem: true` (marker
`__ablated_20802_caller__`). Blob → `b0c1748c5b70`. Predicted red,
observed red:
- objectql 1 failed / 244 passed (the as-the-caller pin);
- rest 2 failed / 13 passed / 6 skipped: the permission pin answered
rows instead of 403, and the row-scope pin returned `d4`.
- (A first run of B used a mutation that left `execCtx` unused, and its
DTS step failed on TS6133; the JS carried the marker. It was re-run
type-clean, and those numbers are the ones above.)
- **Restore.** Blob equals HEAD `9237c3dfc995`, and `git diff HEAD` is
empty. After a rebuild, the `--absent` preflight found both markers
absent from all 14 built files, and the whole tree was clean. The pins
were green again: 245 passed; 15 passed / 6 skipped.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands` at `56da9b6d50` (fresh,
not stale) derived 120 commands, and all 120 were run. `--ran` with each
exit code recorded: **120 derived, 120 run, 0 NOT-MEASURED, 0 UNRUN**,
all exit 0.
Three gates refused first with `PREREQUISITE NOT MET` (exit 3), and none
of the three is counted as a failure:
- `check:skill-examples`
- `check:dual-build-cjs-loads`
- `check:type-check-debt`
They were re-run green after `turbo run build --filter='./packages/*'
--filter='./packages/*/*'`.
Lint, narrowed and proven: `eslint --no-inline-config --format json`
over the 15 changed `.ts` files gave **15 files, 0 errors, 0 warnings**.
- `isPathIgnored` is false for all 15.
- The population is `eslint.config.mjs`'s `**/*.{ts,…}` block.
- `parserOptions.project` / `projectService` are unset for every file.
Type-aware linting is off, so this diff cannot move a verdict on an
untouched file.
## Changesets
- `.changeset/20802-nested-relation-filter-served.md`:
`@objectstack/objectql` `minor`, `Clause-②: yes (widening)`. It says it
supersedes the relation-field paragraph of the pending
`20745-nested-object-door` entry, and it states the in-memory
`$contains` substring caveat.
- `.changeset/20802-nested-relation-prose.md`: `@objectstack/spec`
`patch` (shipped JSDoc).
- `.changeset/20802-dotted-relation-route.md`:
`@objectstack/metadata-protocol` `patch` (refusal words).
- ADR anchor:
`scripts/adr-anchors/packages__objectql__src__relation-filter-lowering.ts.json`
→ ADR-0053.
## Acceptance notes
- **The analytics half.** The cube read and the analytics read-scope
face are not touched here (#20810 first). Until then the analytics face
still flattens the nested form to cube members, and the read scope still
refuses it.
- **The permission refusal's envelope.** It is `PERMISSION_DENIED` /
403, the one existing check, reused. It is not the ruling's
parenthetical `INVALID_FILTER`, and it is raised to the PM as an open
question.
- **Read order.** The related read runs at stage 2, before the outer
verb's own middleware. A caller with no read access to the queried
object still gets the outer 403, but the related read has already run as
that caller. It reads nothing the caller could not read directly.
- **Lint face.** `@objectstack/lint`'s list-view dotted-path hint still
says "Filter on a column of … itself". That is an instruction rather
than a claim this change made false. Carrier: none; not changed here.
- **The in-memory substring gap.** On the in-memory driver, the
multi-valued any-member lowering inherits that driver's
substring-per-element `$contains`. It is reported, not fixed here (no
driver file).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0803a8b commit ca5408c
20 files changed
Lines changed: 1611 additions & 208 deletions
File tree
- .changeset
- content/docs/kernel/contracts
- packages
- metadata-protocol/src
- objectql/src
- rest/src
- spec/src/data
- scripts/adr-anchors
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
178 | 178 | | |
179 | 179 | | |
180 | 180 | | |
181 | | - | |
182 | | - | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
183 | 204 | | |
184 | 205 | | |
| 206 | + | |
| 207 | + | |
185 | 208 | | |
186 | 209 | | |
187 | | - | |
188 | | - | |
189 | | - | |
190 | | - | |
191 | | - | |
192 | | - | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
193 | 215 | | |
194 | 216 | | |
195 | 217 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
263 | 263 | | |
264 | 264 | | |
265 | 265 | | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
266 | 281 | | |
267 | 282 | | |
268 | 283 | | |
| |||
10104 | 10119 | | |
10105 | 10120 | | |
10106 | 10121 | | |
| 10122 | + | |
| 10123 | + | |
| 10124 | + | |
| 10125 | + | |
10107 | 10126 | | |
10108 | 10127 | | |
10109 | | - | |
10110 | | - | |
| 10128 | + | |
| 10129 | + | |
| 10130 | + | |
10111 | 10131 | | |
10112 | 10132 | | |
10113 | 10133 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
33 | 41 | | |
34 | 42 | | |
35 | 43 | | |
| |||
74 | 82 | | |
75 | 83 | | |
76 | 84 | | |
77 | | - | |
78 | | - | |
79 | | - | |
80 | | - | |
81 | | - | |
82 | | - | |
83 | | - | |
84 | | - | |
85 | | - | |
86 | 85 | | |
87 | 86 | | |
88 | 87 | | |
| |||
153 | 152 | | |
154 | 153 | | |
155 | 154 | | |
156 | | - | |
157 | | - | |
158 | | - | |
159 | | - | |
160 | | - | |
161 | | - | |
162 | | - | |
163 | | - | |
164 | | - | |
165 | | - | |
166 | | - | |
167 | | - | |
168 | | - | |
169 | | - | |
170 | | - | |
171 | | - | |
172 | | - | |
173 | | - | |
174 | | - | |
175 | 155 | | |
176 | 156 | | |
177 | 157 | | |
| |||
195 | 175 | | |
196 | 176 | | |
197 | 177 | | |
198 | | - | |
199 | | - | |
200 | | - | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
201 | 182 | | |
202 | 183 | | |
203 | 184 | | |
204 | | - | |
| 185 | + | |
205 | 186 | | |
206 | 187 | | |
207 | 188 | | |
208 | 189 | | |
209 | 190 | | |
210 | 191 | | |
211 | | - | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
212 | 195 | | |
213 | 196 | | |
214 | 197 | | |
| |||
230 | 213 | | |
231 | 214 | | |
232 | 215 | | |
233 | | - | |
| 216 | + | |
234 | 217 | | |
235 | | - | |
| 218 | + | |
236 | 219 | | |
237 | 220 | | |
238 | 221 | | |
239 | 222 | | |
240 | 223 | | |
241 | 224 | | |
242 | 225 | | |
243 | | - | |
| 226 | + | |
244 | 227 | | |
245 | 228 | | |
246 | | - | |
| 229 | + | |
247 | 230 | | |
248 | 231 | | |
249 | 232 | | |
| |||
333 | 316 | | |
334 | 317 | | |
335 | 318 | | |
336 | | - | |
| 319 | + | |
337 | 320 | | |
338 | | - | |
| 321 | + | |
339 | 322 | | |
340 | 323 | | |
341 | 324 | | |
| |||
0 commit comments