Skip to content

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

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20802-relation-filter-lowering
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20802-relation-filter-lowering

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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:

Text.

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.
  • 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): [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 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 (#5930 step 3: the shared filter lowering at the analytics seams (the analytics 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.
  • 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

…als name the nested route

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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/spec, touching 50 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 72f8c3820154c69cdf9faa6f887dc544e4c4b823.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 70 pages)
  • 11 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 72f8c3820154c69cdf9faa6f887dc544e4c4b823 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6dfbe15fcd231284b26b0f0ca86f981072ef980c — the merge of head 56da9b6d50340f2cbc1674236f8957cab1e5c2ff into base 72f8c3820154c69cdf9faa6f887dc544e4c4b823, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 72f8c3820154c69cdf9faa6f887dc544e4c4b823 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 56da9b6d50340f2cbc1674236f8957cab1e5c2ff
Local-runs: none

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 main (git diff origin/main..., merge-base 660a9b247e), the card's body and every comment (5907789183, 5907891818, 5910636932, 5912874377, 5912908152), #20745 with PR #20781 (today's refusal and its table), PR #20794 (cfa931535, the seam), and the head's check-runs. Not the dispatch order, not the seat's conclusions. The head is still 56da9b6d50340f2cbc1674236f8957cab1e5c2ff (a draft, Part of #20802, the declaration on its second line). Nothing was built, run, re-run or ablated; mergeability was read with git merge-tree (an object-database read, no worktree).

Check-runs on this head, as read at the stamp above: 32 runs. 17 success (Auto Label, Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Verify CLI, filter, Flag docs affected, Governed Surface Queue Guard, the two single-writer/claim guards, Part-of PR must not also close its card, Spec property liveness, The card this PR closes must claim this branch, Type Check · debt ledger, Type Check · source gates); 2 skipped (Console Pin Gate, Packed-tarball smoke, both opt-in); 13 in_progress (Test Core 1 to 6, Dogfood Regression Gate 1 to 3, Lint & Repo Gates, Temporal Conformance, Type Check · consumer gates, Type Check · workspace). The in-progress runs are not conclusions and this record does not wait for them; the landing does (AGENTS.md: every check green). Nothing read is red.

① Derived judgments

Each accept-set and public-surface change the diff implies, named right or wrong against the ruling's execution parameters.

  • Where: the [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 seam, drivers untouched, no second walk — right. Stage 2 of where admission is now resolveWhereFilterTokens, then ObjectQL.lowerRelationConditions, then lowerFilterCondition (resolveRelateThenLowerWhere, engine.ts). Every lowerWhereFilterArray call on the branch (find, findOne, update, delete, count, aggregate, and the judge) passes this.relatedSchemaOf, and every one of those verbs awaits resolveWhereTokens / withResolvedWhere with the relation step, so no position admits the form at the door without lowering it before a driver. The only remaining callers of the older resolveThenLowerWhere are having (guarded by position === 'having') and the per-aggregation filter's token pass, neither of which admits the form (their walk contexts carry servesRelations: false). No file under packages/drivers/** changes. The door's admission (admitRelationCondition), the collecting pass and the replacing pass all run through the one walkCondition; the two passes walk the same resolved object in Object.entries order and replace by index, so they cannot disagree about where a condition sits.
  • First cut: one level, forward, any member — right. admitRelationCondition refuses a dotted inner key and a relation-typed inner key holding a no-operator object (second-level), so recursion in the judge is bounded at one; the reverse form is not touched. A multi-valued relation lowers to an $or of one $contains per id (lowerRelationSite), the spec's any-of spelling; the $or is AND-ed into the node (withClauses, appending to an existing list $and, wrapping a non-list one). A single-valued relation lowers to $in.
  • As the caller — right, verified from the code, not the ablation. The inner read is this.find(site.target, { where, fields: ['id'], limit: RELATION_FILTER_ID_CAP + 1, ...(execCtx ? { context: execCtx } : {}) }). execCtx is the verb's own opCtx.context (mergeReadContext(query.context, options.context) on the reads; options.context on update/delete), the same identity the outer middleware later sees; context, fields and limit are in ENGINE_FIND_OPTION_KEYS. Nothing sets isSystem; an absent caller context stays absent, which is what a direct read with no context gets. The inner find enters executeWithMiddleware, where the security plugin's read branch guards opCtx.ast with assertReadableQueryFields (security-plugin.ts, the filter-oracle guard) on the related object and injects that object's RLS. The objectql pin asserts the middleware sees operation: 'find', the caller's userId, and isSystem not true; the rest pin runs the real SecurityPlugin over SQLite through the public door.
  • Bounded, refused, never truncated — right. limit is RELATION_FILTER_ID_CAP + 1, and a matched.length above RELATION_FILTER_ID_CAP throws relationFilterCapError before any outer read. No clamp sits between the engine and the driver: engine.ts, packages/plugins/*/src and packages/drivers/*/src carry no Math.min on limit, no MAX_LIMIT or page ceiling (grepped on the branch). The rest pin inserts 1001 matching owners over SQLite and reads the 400, and 1000 served whole; the objectql pin does the same with the recording driver at cap and cap+1.
  • Empty id set is FALSE, never an absent predicate — right. $in: [] and $or: [] are the 空组合子在同仓有两个对立答案:五个后端归约成布尔单位元,service-analytics 的两个编译器 fail-closed 抛错 —— #5239 的一致性表四条因此进不了表 #5322 identities pinned for every backend in filter-logic-conformance.ts (empty $or matches nothing); SqlDriver.applyFalseConstant renders 1 = 0. The rest pin reads no rows for the single- and multi-valued no-match cases and all four rows for $not over a no-match.
  • Accept set that widens — right, and within "a relation field". where on find / findOne / count / aggregate / update / delete and judgeFilter (ok: true where it answered ok: false), reached from the REST query doors through findData, under every REFERENCE_VALUE_TYPES kind: lookup, master_detail, user, tree, single or multiple. The widening to user and tree is the ruling's "a relation field" as the fleet already reads it: [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 seat answer 5904634845 ruled that one door judges the spec's class REFERENCE_VALUE_TYPES, and serving the same class is the same one-class-one-answer; referenceTargetOf resolves user to sys_user (IMPLICIT_REFERENCE_TARGETS) and refuses loudly where that object is not registered. Public surface: one new root export, RELATION_FILTER_ID_CAP, from @objectstack/objectql (objectql has no api-surface baseline to regenerate). The Clause-② line reads yes (widening) on the PR body's second line and in the objectql changeset; under scripts/pm/clause2-line.mjs that is the widening arm, at least minor, and it answers the grammar's question (the accept set widens from refused to served, the public surface grows by one export). The ruling's own no reading of that line was about the type not moving; the claim 5910636932 named the deviation and left it to the director's veto. The line is right.
  • Nothing narrows — right. The admission runs only where the arm refused (a no-operator object beneath a relation column at where); {} beneath a relation, an unregistered related object, a user field with no sys_user, a second level, a dotted inner key and an undeclared inner key were all INVALID_FILTER / 400 before and stay so, in new words and before any read; the structured-JSON, scalar and provisioned-id verdicts are untouched; the lowering pass (lowerOnly) re-asks only the relation question and skips the number arm, and tokens resolve only to scalars ({current_user_id}, date macros), so a filter stage 1 admitted cannot be refused at stage 2 by the re-walk. A filter with no nested condition returns from lowerRelationConditions by reference (sites.length === 0), and the control pin reads the driver input deep-equal to the input.
  • Kept refusals list — right, and the dotted-path words are true again. The kept refusals are the six named above plus the form at aggregations[i].filter and having (their relationWords now say the engine serves it in where, and a multi-valued column there names no $contains route because the in-process evaluator has no member test), the json object comparand, and the dotted path (INVALID_FIELD). Both dotted relation refusals (filter-comparand-shape.ts and metadata-protocol protocol.ts) drop "a filter reaches only columns of X itself", which this diff makes false, and name the nested route in the same words; no test anywhere on the branch still asserts the old words; the conformance pin holds the two doors' routes equal.
  • Files beyond the claim, each with its reason. number-comparand-declared-type-door.ts (+164 −21) had to change because the ruling forbids a second walk: the one walk that already had each column's declaration in hand gained the relation arm at where (admission instead of refusal), the lowerOnly pass, withClauses and mapRelationConditions; no other form's answer moved there (the no-operator-object arm for scalar and JSON columns, the number arm, the three positions and the boundaries are the same code paths). filter-comparand-shape.ts had to change because its relation-head refusal made a claim this change falsified; only the relation head's words and a nestedRelationRoute helper change. metadata-protocol/src/protocol.ts is the same sentence at the query-parameter door, outside the claim's declared surface and named in the PR body and report; it carries its own patch changeset. The ADR anchor JSON pins ADR-0053 to the new module, as PR feat(spec,objectql,plugin-security): one shared filter lowering, run once at the engine and RLS seams (#5930 step 2) #20794 did for the seam. index.ts is the one export. The rest pins are where [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 lives.
  • Security, adversarially: no path found where the nested form yields what a direct filter on the related object would refuse or hide. Row scope: the inner read carries the caller's context into the related object's RLS, so a hidden related record contributes no id (the rest pin: HIDDEN owner matches nothing for the member, d4 for the system). Field permissions on the inner key: the inner ast.where is the flat related condition, guarded by assertReadableQueryFields on the related object; a refusal propagates from the awaited find and the outer read never runs (both pins). A related object the caller cannot read at all: the inner find meets that object's CRUD gate as a direct read would. The inner read before the outer verb's middleware: it runs as the caller through the same engine entry a direct read uses, so it can return or refuse nothing a direct read would not; a caller without read access to the queried object still gets the outer 403, and the only observable difference is that the related object's hooks and audit see a read the caller could have made directly. Error text: describeKeys names keys, never values; the cap words name the cap, the relation and the related object; the related object's own doors echo the caller's comparand, not stored data; the count the cap discloses is a count of rows the caller can see. Two disclosures were weighed and judged not leaks of data: the structural refusals name whether an inner key is declared or is itself a relation of the related object (schema shape, already readable through the dotted-path door's head classification and the metadata routes). $not over a scoped inner read negates only the ids the caller may see, which is exactly what the hand-written two-step route answers.
  • Open question A (5912908152) — keeps the ruling's operative guarantees; no review face claims otherwise. Loud (403), names the field (toContain('secret') on both the direct and nested refusals), never empty (records undefined beside the refusal), and it is the one check a direct filter meets. The changeset, the PR body and data-engine.mdx say PERMISSION_DENIED / 403; the spec docblock says "refused, never answered empty" and names no code. No face writes INVALID_FILTER for that case. The veto path (the security layer's envelope for all filter-oracle refusals, its own card) is stated on the seat's answer.
  • Review faces, sentence by sentence. The three changesets: true (the objectql entry's supersession claim holds, 20745-nested-object-door.md is still pending on main; the memory $contains caveat is stated). The PR body: true on every sentence checked against the diff, the seam and the ruling, including the H1 to H7 readings and the measured tables it labels as local (the PostgreSQL column and the InMemoryDriver column are measured, not CI-pinned, and the body says so). filter.zod.ts docblock item 4, the QueryFilter example and the Filter nested-arm comment: true. data-engine.mdx: true. The refusal texts (the cap, the kept refusals, both dotted-path doors): true. One precision nit, not a false sentence: the docblock, the mdx and the changeset say every inner key must be one the related object declares, while admitRelationCondition also admits the platform-provisioned id / created_at / updated_at when the declared map omits them (the objectql pin uses { owner: { id: '{current_user_id}' } } on an owner object declaring no id); the fleet's own reading (5904634845) counts those three as declared on every object, and the served set is wider than the words only in that direction.

② Semver level

  • @objectstack/objectql minor — right. An additive widening takes at least minor (the WHICH LEVEL ruling finding(changeset): two independent contract reviews read the repo's own history to opposite bumps for "add an exported symbol to a published index" #15294), and the package gains one root export. No BREAKING banner is due: nothing served today narrows, so no ADR-0087 disposition is owed.
  • @objectstack/spec patch — right. filter.zod.ts changes are prose only (the docblock, the example, one comment in checkFilterConditionComparands, one comment on Filter); the type and the schema are unchanged; Build Docs and Spec property liveness are green on the head.
  • @objectstack/metadata-protocol patch — right. The words of an INVALID_FIELD / 400 refusal change; its envelope and the door's accept set do not.
  • The declaration reads yes (widening) on the PR body and the objectql changeset, the arm clause2-line.mjs reads as a widening at minor or above; it matches what the diff publishes.

③ Boundary flags

Every dev flag and every open_questions entry, answered or escalated.

Implemented-by: claude/issue-20802-relation-filter-lowering
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 14:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit ca5408c Sep 30, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20802-relation-filter-lowering branch September 30, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants