feat(objectql): serve the nested-relation filter in where — lowered at the engine seam, the related object read as the caller, a loud cap, drivers untouched (#20802) - #20872
Conversation
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…als name the nested route Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…e REST bound Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…esets, ADR anchor Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…stage Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…lation-filter-lowering
…'s words Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…lation-filter-lowering
📓 Docs Drift CheckThis PR changes 3 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6dfbe15fcd231284b26b0f0ca86f981072ef980c && git checkout 6dfbe15fcd231284b26b0f0ca86f981072ef980c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72f8c3820154c69cdf9faa6f887dc544e4c4b823 56da9b6d50340f2cbc1674236f8957cab1e5c2ff && git checkout -B drift-repro 72f8c3820154c69cdf9faa6f887dc544e4c4b823 && git merge --no-ff 56da9b6d50340f2cbc1674236f8957cab1e5c2ff
node scripts/docs-audit/affected-docs.mjs --json 72f8c3820154c69cdf9faa6f887dc544e4c4b823
|
Contract reviewServed-tier: At-tier review of PR #20872, the engine half of card #20802 (ruling 5907789183, letter A), rendered at 2026-09-30T14:19Z. Read only: the PR body, its 20-file list and the net diff against Check-runs on this head, as read at the stamp above: 32 runs. 17 ① Derived judgmentsEach accept-set and public-surface change the diff implies, named right or wrong against the ruling's execution parameters.
② Semver level
③ Boundary flagsEvery dev flag and every
Implemented-by: VERDICT: PASS Generated by Claude Code |
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 inwhere. It is lowered at the engine's filter seam that #5930 step 2 built (cfa931535). The drivers receive$in/$containsand are not changed (ADR-0053 D-D1 item 5, D4 (b)).whereadmission now has three steps: resolve the placeholders, then lower each nested-relation condition, then run the sharedlowerFilterCondition.ObjectQL.resolveRelateThenLowerWhereholds that order, so a verb cannot resolve without it.resolveWhereTokensandwithResolvedWherebecame async to carry it.lowerRelationConditionsreads the related object with the engine's ownfind: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$orof one$containsper id, which matches on any member. That is the spec's own any-of spelling, and the SQL family refuses$inover the JSON column. No related record matching gives$in: []or$or: []: FALSE, never an absent predicate.walkConditioninnumber-comparand-declared-type-door.ts.admitRelationCondition), instead of refusing it.mapRelationConditions) twice: once to collect the conditions, and once, after the reads, to replace them in the same order.RELATION_FILTER_ID_CAP= 1000, one named constant, exported from@objectstack/objectql. Past it the filter is refused withINVALID_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) plusjudgeFilter, and the REST query doors that reachfindData(POST /api/v1/data/:object/queryand thefilter/$filterspellings). Per relation kind:lookup,master_detail,user,tree, single-valued: refused, now served ($in).multiple: true: refused, now served (any member).Nothing served today narrows.
where.What stays refused. Each is refused in the engine's words, before any read:
jsonfield's object comparand, and the provisionedid(unchanged);{};{ 'owner.region': 'NA' }, stillINVALID_FIELD([finding] The FILTER axis has no DOTTED-path verdict —where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371);filterand inhaving. The engine evaluates both itself, and its evaluator has no member test for a stored list. Their words now saywhereserves it.Text.
FilterCondition's docblock item 4 now states the served semantics.QueryFilterexample shows the form again.data-engine.mdxexample that PR fix(objectql)!: a no-operator object beneath a relation, structured-JSON or undeclared id column is refused INVALID_FILTER / 400 on every driver (#20745) #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).
filter-comparand-shape.ts) and the query-parameter door's (metadata-protocolprotocol.ts, outside the claim's declared file surface).{ "owner": { "region": VALUE } }), in the same words, and keep the shared denormalise remedy.Measured
On this branch at
56da9b6d50throughPOST /api/v1/data/:object/query. Owneru1is region NA ond1andd3, andd4has no owner. Before, onorigin/mainafter PR #20781, every relation row answeredINVALID_FILTER/ 400 on every driver.where{ owner: { region: 'NA' } }(lookup),boss(master_detail)d1,d3d1,d3d1,d3{ owners: { region: 'NA' } }(multiple lookup)d1,d3d1,d3d1,d3(see the note){ parent: { title: 'a' } }(tree)d2,d3d2,d3d2,d3{ $not: { owner: { region: 'NA' } } }d2,d4d2,d4d2,d4{ $or: [{ owner: { region: 'EU' } }, { title: 'a' }] }d1,d2d1,d2d1,d2{ owner: { region: 'APAC' } }(no match)INVALID_FILTER, the cap words$containsover a stored array by substring per element. That is the gapFILTER_OPERATORS'$containsdocblock records for that driver. So with idsu1andu10, a multi-valued condition meaningu1also matches the row holding['u10']: measured memoryd1,d3,d5, against SQLd1,d3. Single-valued relations are exact everywhere.check:driver-memory-censusrefuses a new test consumer of that driver without a ruling.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
lowerFilterConditionis 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.whereonfind,findOne,count,aggregate,updateanddelete(the multi and by-predicate paths alike), and the judge.aggregations[i].filterandhaving.REFERENCE_VALUE_TYPESkind, not onlylookup/master_detail.userandtreepoint at a related object the same way, and the [finding] a no-operator object under a lookup, master_detail or json field answers per driver: the declared nested-relation filter returns no rows on memory and a 400 on SQL, and a json object comparand deep-equals on memory and is refused on SQL #20745 seat answer ruled that one class gets one answer.userneedssys_userregistered; where it is not, it is refused loudly ("no object 'sys_user' is registered here").403 PERMISSION_DENIED, the security layer's filter-oracle guardassertReadableQueryFields.INVALID_FILTER; see the open question in the report.$indoes not mean "any member" everywhere;$orof$containsdoes on SQL.$inover a multi-valued lookup is refused on SQL (JSON column) and is any-member on memory.$containsis membership on SQLite and PostgreSQL, and substring-per-element on memory (the note above).$orof$containsis the spec's declared any-of spelling, so it is the lowered form.expand's batch loader bounds nothing: it deliberately forwards no limit. The cap is a new named constant.$and/$orcompose as written.$notover 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".$noris not in the vocabulary.Tests (all on
56da9b6d50)@objectstack/objectqltest: 345 files / 6786 passed. typecheck exit 0,check:test-typecheckOK.@objectstack/resttest, withOS_TEST_POSTGRES_URLset to a local PostgreSQL 16.13: 237 files / 4706 passed / 35 skipped (MySQL cells and suites with no URL). typecheck exit 0.@objectstack/metadata-protocoltest: 191 files passed, 3 skipped / 2801 passed, 19 skipped. typecheck exit 0.@objectstack/spectest: 578 files / 17066 passed / 1 todo. typecheck exit 0.check:generated: all 15 artifacts up to date (no regeneration needed; docblock only).@objectstack/plugin-security149 files / 3227 passed, 23 skipped.driver-memory65 / 1470 passed.driver-sql201 files passed, 11 skipped / 3254 passed, 188 skipped. The other...@objectstack/objectqlconsumers are declared to CI.packages/objectql/src/engine-nested-relation-lowering.test.ts(13 tests, recording driver). It covers:$and/$or/$not/ sugar, pinned equal to the two-step route's driver input;filter/havingrefusals;packages/rest/src/data-nested-object-door.test.ts(rewritten): [finding] a no-operator object under a lookup, master_detail or json field answers per driver: the declared nested-relation filter returns no rows on memory and a 400 on SQL, and a json object comparand deep-equals on memory and is refused on SQL #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 realSecurityPluginon 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.engine-nested-object-door.test.tskeeps 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.mjsin WRAP mode, trap-restored. After each mutation objectql was rebuilt, andablation-dist-preflightfound the marker in 4 built files.if (sites.length === 0) return where;became an unconditionalreturn where(marker__ablated_20802_lowering__). Blob9237c3dfc995→f9994599356f. Predicted red, observed red:...(execCtx ? { context: execCtx } : {}),becameisSystem: true(marker__ablated_20802_caller__). Blob →b0c1748c5b70. Predicted red, observed red:d4.execCtxunused, 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.)9237c3dfc995, andgit diff HEADis empty. After a rebuild, the--absentpreflight 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 --commandsat56da9b6d50(fresh, not stale) derived 120 commands, and all 120 were run.--ranwith 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-examplescheck:dual-build-cjs-loadscheck:type-check-debtThey were re-run green after
turbo run build --filter='./packages/*' --filter='./packages/*/*'.Lint, narrowed and proven:
eslint --no-inline-config --format jsonover the 15 changed.tsfiles gave 15 files, 0 errors, 0 warnings.isPathIgnoredis false for all 15.eslint.config.mjs's**/*.{ts,…}block.parserOptions.project/projectServiceare 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/objectqlminor,Clause-②: yes (widening). It says it supersedes the relation-field paragraph of the pending20745-nested-object-doorentry, and it states the in-memory$containssubstring caveat..changeset/20802-nested-relation-prose.md:@objectstack/specpatch(shipped JSDoc)..changeset/20802-dotted-relation-route.md:@objectstack/metadata-protocolpatch(refusal words).scripts/adr-anchors/packages__objectql__src__relation-filter-lowering.ts.json→ ADR-0053.Acceptance notes
where/ preview door, the read scope) and the memory cube face's door, with the F5 / F11 output vocabulary #20810 first). Until then the analytics face still flattens the nested form to cube members, and the read scope still refuses it.PERMISSION_DENIED/ 403, the one existing check, reused. It is not the ruling's parentheticalINVALID_FILTER, and it is raised to the PM as an open question.@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.$contains. It is reported, not fixed here (no driver file).Generated by Claude Code