Skip to content

The Where vocabulary and its lowering; the filter module retyped - #1220

Merged
borisno2 merged 2 commits into
prisma-8from
claude/issue-1147-where-vocabulary
Sep 6, 2026
Merged

The Where vocabulary and its lowering; the filter module retyped#1220
borisno2 merged 2 commits into
prisma-8from
claude/issue-1147-where-vocabulary

Conversation

@borisno2

@borisno2 borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member

Implements #1147. Part of #1123.

Builds on #1146 (26f47b0a): the lowerPredicate seam that ticket deliberately narrowed to equality is replaced by a two-pass vocabulary, and the generated ListPredicate widens with it.

What landed

.where() and .orderBy() take ADR-0055's closed Where vocabulary:

const posts = await context.db.Post.where({
  OR: [{ title: { contains: 'release' } }, { views: { gte: 100 } }],
  author: { some: { handle: { equals: 'ada' } } },
})
  .orderBy({ views: 'desc' })
  .all()

Lowering is split in two, which is what lets a relation quantifier's predicate be a plain synchronous lambda:

  • secured/vocabulary.ts — pure, ORM-free. Resolves a predicate against the list config and the session: every key checked, every operator checked against the closed set, every relation hop's query access decided. Produces a plan.
  • secured/lower.ts — the one place the vocabulary meets the ORM. Builds the expression from a plan. Prisma's and/or combinators are loaded through a lazy import() so the package root keeps its static module graph free of @prisma/orm-postgres.
  • secured/operators.ts — the operator set as a leaf module, so the vocabulary and read-path key validation can both name it without importing each other.

Behaviour, per the acceptance criteria:

  • Every operator lowers and returns the expected rows on the Test context. contains is engine-escaped and case-insensitive, so contains: '50%' matches a literal per-cent sign rather than binding a wildcard.
  • equals: null lowers to IS NULL, not: null to IS NOT NULL.
  • A relation predicate ANDs the related list's own query access inside the EXISTS. A related list the session cannot query is the empty set: some false, none/every true.
  • orderBy is scalar-only; a relation is refused rather than ignored.
  • An unknown key or operator is a ValidationError naming the list and the key, under sudo too — sudo bypasses access, not the vocabulary.
  • The filter module's FilterCondition is a vocabulary value and still imports nothing from the ORM (pinned by a new src/filter/purity.test.ts, which also asserts the deleted count-filter symbols are gone).

The totality finding (the carried-forward comment on #1147)

lowerPredicate used to do if (condition === undefined) continue. Because the Access Filter is lowered through the same seam, an access rule spelled

({ session }) => ({ authorId: session?.userId })

lowered to {} for an anonymous caller and matched every row, while the explicit { equals: undefined } spelling of the same rule was refused — two spellings of one rule behaving oppositely, and the widening one the idiomatic spelling.

Both spellings now throw. resolveKey refuses an undefined condition, and so does every operator slot inside a condition object; the Access Filter is resolved through the same call with only the field-read gate relaxed (it is trusted config), so it cannot lower to a widening predicate. The tests #1146 added to pin the old behaviour — including the end-to-end Widened fixture proving the anonymous widening — are inverted, not deleted: they now assert the refusal, and the authenticated case still returns the correctly-scoped row.

The refusal is not an oracle. A key the session cannot read is refused with the identical message a key the list does not declare gets (a test compares the two message strings), and #1146's ordering is preserved — operation access resolves first, so a denied caller gets the Silent failure and never reaches key validation at all.

Blast radius on existing config. I swept packages and examples for the widening signature (session?. inside a filter-returning access rule). Every example config already guards with if (!session) return false, so no runtime config broke. What did need fixing was documentation that taught the widening idiom: docs/content/reference/config-api.md and docs/content/how-to/anonymous-access-control.md now say the engine refuses rather than silently matching everything, and the CLI's MCP documentation provider no longer hands out query: ({ session }) => ({ userId: { equals: session?.userId } }) as an example.

Deletions, and the tests that moved with them

  • RELATIONSHIP_COUNT_FILTER_KEY, RelationshipCountFilterMarker, resolveRelationshipCountFilters, mergeResolvedMember, and the whole relationship-label-filter.ts module (resolveRelationshipLabelFilters, isToOneRelationshipField). buildRelationshipCountSelect and isToManyRelationshipField stay — count display is unaffected.
  • A to-one relationship's label filter emits { author: { some: { name: { contains } } } } rather than is (Prisma 8 has no to-one predicate form). A to-many count filter shrinks to presence: orders:0none, orders:>0 / orders:>=1some, any other comparison degrades to free text under ADR-0017's own rule, so a bookmarked orders:>5 stops filtering rather than erroring.
  • packages/ui's ListView no longer calls the count resolver; its two tests changed with the behaviour.
  • query-validation.ts gained the operator check, which is why two long-standing startsWith probes in packages/core/tests/context.test.ts now use containsstartsWith/endsWith/mode/search are outside the vocabulary by decision, not oversight.

Out of scope, left as clean seams

Refinements and the depth cap (#1148), select / widen-and-strip (#1149), aggregate/combine/distinct/cursor (#1150), nearest() (#1151), all writes (#1152/#1124).

Verification

  • pnpm build — 11/11 tasks successful
  • tsc --noEmit on packages/core — clean
  • pnpm lint — 0 errors (2 pre-existing warnings, both untouched by this change)
  • pnpm -r --filter='./packages/*' test — all 9 packages green; core 1552 passed / 1 skipped across 78 files
  • pnpm manypkg fix and pnpm format run

🤖 Generated with Claude Code

`.where()` and `.orderBy()` take the closed Where vocabulary — the nine
scalar operators, AND/OR/NOT, and some/every/none on a relation of any
cardinality — resolved against the config in one pass and lowered onto
Prisma's predicate lambda in another. `contains` is engine-escaped and
case-insensitive, `equals: null` is IS NULL, a relation predicate ANDs the
related list's `query` access inside the EXISTS (denied meaning empty), and
`orderBy` is scalar-only.

The lowering is now total: a condition that resolved to `undefined` is
refused rather than dropped, on both spellings. The Access Filter goes
through the same seam, so an access rule spelled
`({ session }) => ({ authorId: session?.userId })` no longer matches every
row for an anonymous caller — it throws. The tests that pinned the old
widening are inverted rather than deleted.

An unknown key or operator is a ValidationError naming the list and the key,
`sudo` included. A key the session cannot read carries the identical message
a key the list does not declare gets, and access is still resolved first, so
a denied caller sees the Silent failure and no validation error at all.

The filter module's condition type is a vocabulary value and still imports
nothing from the ORM; a to-one label filter emits `some`, a to-many count
filter shrinks to presence, and the count-filter marker, its resolvers and
the label pass-through are deleted.

Implements #1147. Part of #1123.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: eda95d0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@opensaas/stack-core Minor
@opensaas/stack-ui Minor
@opensaas/stack-cli Minor
@opensaas/stack-auth Minor
@opensaas/stack-rag Minor
@opensaas/stack-storage Minor
@opensaas/stack-tiptap Minor
@opensaas/stack-storage-s3 Minor
@opensaas/stack-storage-vercel Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
stack-docs Ready Ready Preview Sep 6, 2026 1:15pm UTC

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🟢 Lines 94.26% (🎯 65%) 2810 / 2981
🟢 Statements 92.96% (🎯 65%) 3078 / 3311
🟢 Functions 96.68% (🎯 62%) 555 / 574
🟢 Branches 87.76% (🎯 50%) 2167 / 2469
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/access/engine.ts 97.01% 98.07% 100% 96.49% 37, 214
packages/core/src/access/errors.ts 100% 87.5% 100% 100%
packages/core/src/access/query-validation.ts 78.9% 73.27% 100% 83.63% 144-146, 227-234, 239, 255, 305, 308-311, 322-336, 338, 346-357, 441, 445, 451, 469, 472-473, 500
packages/core/src/access/relationship-count.ts 91.66% 92.59% 100% 100% 85, 87
packages/core/src/filter/collect.ts 100% 83.33% 100% 100%
packages/core/src/secured/lower.ts 87.5% 76.08% 80% 90.47% 76, 87, 146, 155, 163, 174
packages/core/src/secured/operators.ts 100% 100% 100% 100%
packages/core/src/secured/read.ts 90.47% 83.33% 95.65% 94.23% 66-70, 87, 89, 96
packages/core/src/secured/vocabulary.ts 86.84% 82.53% 94.73% 87.69% 101, 248, 260, 270, 281, 286, 302, 310, 316, 319, 331, 348, 355, 373, 375, 380-384, 391, 392, 394, 509
Generated in workflow #2005 for commit eda95d0 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 78.45% 244 / 311
🔵 Statements 77.95% 251 / 322
🔵 Functions 69.81% 74 / 106
🔵 Branches 66.94% 160 / 239
File CoverageNo changed files found.
Generated in workflow #2005 for commit eda95d0 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 72.31% 1392 / 1925
🔵 Statements 72.37% 1493 / 2063
🔵 Functions 81.81% 234 / 286
🔵 Branches 60.19% 738 / 1226
File CoverageNo changed files found.
Generated in workflow #2005 for commit eda95d0 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 99.48% 195 / 196
🔵 Statements 98.13% 210 / 214
🔵 Functions 100% 44 / 44
🔵 Branches 90.77% 187 / 206
File CoverageNo changed files found.
Generated in workflow #2005 for commit eda95d0 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 79.66% 235 / 295
🔵 Statements 81.17% 263 / 324
🔵 Functions 87.91% 80 / 91
🔵 Branches 77.46% 220 / 284
File CoverageNo changed files found.
Generated in workflow #2005 for commit eda95d0 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 54.38% 397 / 730
🔵 Statements 53.85% 419 / 778
🔵 Functions 64.06% 82 / 128
🔵 Branches 47.25% 198 / 419
File CoverageNo changed files found.
Generated in workflow #2005 for commit eda95d0 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 40 / 40
🔵 Statements 100% 40 / 40
🔵 Functions 100% 9 / 9
🔵 Branches 100% 19 / 19
File CoverageNo changed files found.
Generated in workflow #2005 for commit eda95d0 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 68 / 68
🔵 Statements 100% 71 / 71
🔵 Functions 100% 15 / 15
🔵 Branches 97.87% 46 / 47
File CoverageNo changed files found.
Generated in workflow #2005 for commit eda95d0 by the Vitest Coverage Report Action

if (access === false) return { kind: 'false' }
if (access === true) return { kind: 'true' }
const filter: PrismaFilter = access
return await resolveWhere(filter, {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded recursion: a relation named inside an Access Filter re-enters access resolution.

relatedAccessPlan resolves the related list's own Access Filter through resolveWhere while carrying applyRelationAccess forward unchanged (only checkFieldRead is overridden). So a relation key inside an access filter triggers another relatedAccessPlan, which resolves another access filter, and so on — with no cycle set and no depth cap.

Self-referential list is enough to hang a request:

Comment: list({
  fields: {
    published: checkbox(),
    parent: relationship({ ref: 'Comment.children' }),
    children: relationship({ ref: 'Comment.parent', many: true }),
  },
  access: { operation: { query: () => ({ parent: { some: { published: { equals: true } } } }) } },
})

context.db.Comment.all()resolvePlan resolves the filter → key parentresolveRelationrelatedAccessPlan(Comment) → same filter → parent → … forever. Two mutually-referencing lists (Post.query naming author, User.query naming posts) do the same. The terminal never returns; the async chain grows until the process OOMs.

The seam this replaces did not have this shape: buildAccessScopedWhere folds accessWhere into the nested clause and recurses only into the caller's nested clause, never into the access filter it just folded in.

Either resolve access filters with applyRelationAccess: false, or thread a visited-list set (or the #1148 depth cap) through ResolveContext.

Comment thread packages/core/src/secured/vocabulary.ts Outdated
relation: key,
relatedListName: related.listName,
quantifier: quantifier === 'some' ? 'some' : quantifier === 'every' ? 'every' : 'none',
node: access.kind === 'true' ? caller : { kind: 'and', nodes: [caller, access] },

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

every ANDs the access filter into the quantified predicate, which is not "every visible row matches" — and it leaks the existence of invisible rows.

For some and none, caller AND access is right. For every it inverts the intent: every(caller ∧ access) requires every related row to be access-visible and matching, rather than every visible row matching (none(access ∧ ¬caller)).

Concretely, with the fixture in read.test.ts: anonymously Post scopes to published, and handles({ posts: { every: { published: true } } }) returns only ['bob'] — ada drops out solely because she owns a draft the caller cannot see. The caller has just learned that an invisible Post row exists for ada, which is the class of signal the { kind: 'false' } branch above exists to close.

The legacy path made the same conservative choice but documented it loudly (access-filter.ts, "One quantifier is deliberately conservative rather than exactly precise…"). Here neither the code nor the changeset says it, and the test comment on line ~527 asserts the opposite ("every is measured against the same scoped set"). If the imprecision is intentional, please carry the old caveat forward; otherwise lower every as none(access AND NOT caller).

Comment thread packages/core/src/secured/vocabulary.ts Outdated
}
const resolved = resolveQueryField(key, ctx.listConfig.fields)
if (!resolved) throw unqueryableKey(ctx.listName, key)
if (resolved.isRelationship) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

orderBy checks isRelationship before the field-read gate, so a read-denied relationship field is an existence oracle.

resolveKey (the where path) gets the order right: resolveQueryField → field-read check → relation branch, so a read-denied key always comes back with the identical unqueryableKey message. resolveOrderBy does it the other way round — the relation refusal on line 439 runs before the checkFieldRead block on line 443.

Given secrets: relationship({ ref: 'Secret.owner', many: true, access: { read: () => false } }):

  • .orderBy({ secrets: 'asc' })Cannot order "User" by "secrets" — orderBy takes scalar columns only.
  • .orderBy({ nope: 'asc' })… "nope" is not a queryable field of this list.

The caller can now tell that secrets exists and is a relation, which is exactly what ADR-0031 / #915 close elsewhere (and what the read.test.ts message-equality test asserts for where). Move the field-read check above the relationship branch.

// `some` is false, `none` and `every` are true. That keeps a relation
// token from distinguishing parent rows by a list the session cannot see.
if (access.kind === 'false') {
nodes.push(quantifier === 'some' ? { kind: 'false' } : { kind: 'true' })

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The denied-relation short circuit skips validating the nested predicate, which makes "this related list denies query" observable.

When access.kind === 'false' the loop continues without ever calling resolveWhere(nested, …), so nothing inside that predicate is checked. A caller can probe with a deliberately bogus nested key:

  • where({ secrets: { some: { nope: 1 } } }) → resolves fine, returns []
  • where({ posts: { some: { nope: 1 } } })ValidationError: Cannot query "Post" — "nope" …

So the refusal (or its absence) distinguishes a related list the session cannot query from one it can — the very distinction the empty-set treatment on these lines is meant to erase. Resolving nested for its validation side effect and discarding the plan keeps the empty-set semantics while making the two cases refuse identically.

authorId: { equals: session?.userId },
})
// Filter object: Scope access to specific records. Deny outright when there is
// no session to scope to — the engine refuses a predicate that resolved to

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"the engine refuses a predicate that resolved to undefined" is only true of the secured surface — the findMany path still fails open.

The totality fix lives in resolveWhere, which only context.db.<List>.where(...).all()/.first() goes through. createFindMany (and count/updateMany/delete) still does mergeFilters(scopedWhere, accessResult), which just returns { AND: [accessFilter, userFilter] } — an access filter of { authorId: undefined } reaches the client with the key dropped, i.e. it still matches every row.

So for an app on context.db.Post.findMany() — the API this very file and the generated context's own docblock document — query: ({ session }) => ({ authorId: session?.userId }) remains the silent match-everything read for an anonymous caller, while this doc now tells the reader it is refused. Same for the anonymous-access-control.md hunk.

Either scope the wording to the composed read surface, or route the legacy merge through the same total resolution.

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES (posted as a comment review — GitHub refuses a formal Request-changes verdict from the PR author identity).

Code review — high effort

Verdict: REQUEST CHANGES. One blocking defect (unbounded recursion, DoS-reachable), plus three correctness/leak issues and a docs overclaim. Five inline comments are attached to the exact lines.

The core of this PR is right: predicate lowering really is total now, the vocabulary is closed, and the contains escaping is correct. The problems are all in the relation and access-filter edges.

Blocking

  1. Unbounded recursion in relatedAccessPlanpackages/core/src/secured/vocabulary.ts:307. When it recurses to resolve the related list's Access Filter it clears checkFieldRead but leaves applyRelationAccess: true. So a relation key inside an access filter re-enters access resolution, with no cycle set and no depth cap. A self-referential query filter (Comment.query = () => ({ parent: { some: … } })), or two mutually-referencing lists, makes every read of that list recurse until the process OOMs. The legacy buildAccessScopedWhere recursed only into the caller's clause, never into the filter it folded in. Needs applyRelationAccess: false on the recursive call, or a visited-set/depth cap.

Should fix before merge

  1. every has the wrong empty-set semantics — vocabulary.ts:365. every gets caller AND access like some/none, which means "every related row is visible and matches" rather than "every visible row matches". The PR's own test demonstrates the leak: anonymously, ada drops out of { posts: { every: { published: true } } } purely because she owns an invisible draft — that is a positive signal about a row the caller cannot see, exactly the class of leak the denied-list branch two lines above exists to prevent. The legacy path made the same conservative choice but said so; here the code is silent and the test comment claims the opposite. Either fix the lowering (every should not AND the access filter into the quantifier body) or state the trade-off in the code and correct the test comment.

  2. orderBy is an existence oracle — vocabulary.ts:438. The isRelationship refusal runs before the field-read gate; resolveKey on the where path gets this order right. orderBy({ secretRel: 'asc' }) on a read-denied relationship returns "orderBy takes scalar columns only", while an undeclared key returns "not a queryable field" — two distinguishable errors, contradicting this PR's own stated invariant that a read-denied field must be indistinguishable from a nonexistent one. Move the field-read check above the relationship refusal.

  3. Docs overclaim the fix — docs/content/reference/config-api.md:506 (and the anonymous-access-control.md hunk). "The engine refuses a predicate that resolved to undefined" is true only for .where().all()/.first(). createFindMany/count/updateMany/delete still route through mergeFilters (context/index.ts:1198,1362,1641), which passes { authorId: undefined } to the client verbatim. The anonymous match-everything read is unchanged on the path these docs describe and the generated context actually exposes. Either scope the wording to the builder terminals or close the hole in mergeFilters too.

  4. Denied-related-list short circuit skips nested validation — vocabulary.ts:350 (low). The continue returns without resolving the nested predicate, so where({ secrets: { some: { nope: 1 } } }) succeeds while where({ posts: { some: { nope: 1 } } }) throws — the refusal itself distinguishes a related list the session cannot query from one it can, undoing the indistinguishability those lines exist to provide.

Checked and clean

  • undefined totality (both spellings). resolveKey throws on a bare undefined value and resolveScalar throws on { equals: undefined } — the ({ session }) => ({ authorId: session?.userId }) hole is genuinely closed on the builder path (see item 4 for the path where it is not).
  • Test inversion. Checked git diff on every touched test file. The changes are honest adaptations to the Prisma 8 vocabulary (startsWith removed, count-marker → presence), not inversions concealing a regression. relationship-label-filter.test.ts is deleted alongside its module. Worth confirming as a product decision, though: posts:>5 in a saved list filter now silently degrades to free text ({ name: { contains: '5' } }) rather than filtering. It is tested and deliberate, but it is a user-visible loss.
  • contains escaping. containsPattern escapes \ first, then % and _ — correct order, all three characters, and ilike supplies case-insensitivity. No ESCAPE clause is needed because the pattern is a bind value and Postgres's default LIKE escape is backslash; ADR-0055 is cited in the docblock.
  • Deletions. RELATIONSHIP_COUNT_FILTER_KEY, RelationshipCountFilterMarker, resolveRelationshipCountFilters, resolveRelationshipLabelFilters, isToOneRelationshipField, ColumnEquality, UnsupportedPredicateError — no dangling references across packages, examples or docs; ListView's dropped count-resolver call is covered by the updated ListView.test.tsx.
  • No any or type assertions introduced around ListPredicate/ListQuery/ListSort or the dynamic import; ListPredicate/ListSort reach the list surface through ListOps as intended.
  • sudo still passes through vocabulary validation while skipping access — correct.

Minor

  • Lazy ORM import (secured/lower.ts:66). pending ??= import(...) is race-free (assignment precedes any await) and costs one import, not one per call — but a rejected import is cached permanently, so a transient failure poisons the process. Consider clearing pending on rejection.
  • The purity invariant is enforced, but by proxy. filter/purity.test.ts greps a hand-listed file set for @prisma/ — it does not walk the package root's real import graph, and lower.ts (the file carrying the invariant comment) is excluded from the list because it holds a type-only import. Reasonable, but weaker than the comment claims.
  • Changeset is minor for @opensaas/stack-core while removing four public exports. Fine if prisma-8 ships as a single major — worth confirming.

…and mergeFilters

Review fixes on #1220.

An Access Filter that scopes by a relation is expanded into the related list's
own Access Filter, so a filter that names its own list — or two that name each
other — recursed until the process ran out of memory. The expansion now carries
the path of lists it is expanding and refuses a repeat by name, with a second
bound at ten lists deep for an acyclic chain. The refusal is an error naming the
chain rather than a truncated filter: a truncated Access Filter is a widened
read.

`every` asked whether every related row was visible AND matched, which dropped a
parent for owning a row the caller could not see. It now asks whether every
VISIBLE row matches — lowered as "no visible row fails the predicate" — so an
invisible row never decides a parent's membership. `some` and `none` are
unchanged, and a denied related list is still the empty set.

`orderBy` ran its relationship refusal before the field-read gate, so ordering by
a read-denied relationship answered differently from ordering by a key the list
does not declare. The gate now runs first, as `resolveKey` already did. The
denied-related-list short circuit likewise validated nothing nested inside it, so
the refusal itself said which related lists the session could query; the nested
predicate is resolved first.

The `undefined` refusal reached only the secured builder's terminals, while the
docs claimed it unqualified. `mergeFilters` — the seam the legacy findMany,
count, updateMany and delete paths fold their access filter through — now applies
the same rule, throwing `UndefinedAccessFilterError` for an `undefined` condition
anywhere in what an access rule returned. A caller's own `where` is untouched.

The filter module's purity test walks the real import graph from its own files
rather than grepping a hand-listed set, with a negative control proving the walk
detects an ORM import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borisno2

borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Review fixes pushed as eda95d0f. Per numbered finding:

1. Unbounded recursion in relatedAccessPlan (BLOCKING) — fixed.
ResolveContext now carries accessFilterPath: the lists whose Access Filter is currently being expanded, outermost first. relatedAccessPlan refuses a repeat by name (AccessFilterRecursionError, reason: 'cycle') and, separately, an acyclic chain past ACCESS_FILTER_MAX_DEPTH. read.ts seeds the path with the top-level list when it resolves that list's own filter, so the mutual case is caught on the first re-entry.

I kept applyRelationAccess: true on the recursive call rather than clearing it — clearing it would silently drop the inner list's own scoping, which is the same widening the cap is there to prevent. Cycle detection is the correct guard; the depth cap is the belt.

Cap: 10, and why. This recursion is the engine's own — a relation key inside a trusted config filter — so ADR-0043's caller-facing depth cap does not bound it and reusing that number would conflate two different things. With cycle detection in place the chain is already bounded by the number of distinct lists, so the cap only ever fires on an acyclic chain of ten different lists each scoping by a relation into the next. That is well past any real ownership chain (Comment → Post → Blog → Workspace → Org is five) and far short of a stack the process cannot hold.

Fails closed and loudly, as asked: both reasons throw an error naming the full chain (Left → Right → Left), never a truncated filter. AccessFilterRecursionError is deliberately not a ValidationError — it is a configuration fault in a trusted rule, not caller input, and code catching ValidationError for form errors must not swallow it. Exported from the package entry alongside ACCESS_FILTER_MAX_DEPTH.

Tested on both shapes with real rows: a self-referential filter (SelfRef) and a mutual pair (Left/Right), plus an assertion that no plan reaches the database and that seeding a matching row still yields the error rather than any result set.

2. every empty-set semantics (BLOCKING) — fixed.
every no longer ANDs the access filter into the quantifier body. It lowers as "there exists no visible row that fails the predicate" — a none quantifier over visible AND NOT caller — which is exactly "every row the caller may see also matches".

Confirmed the other two: some is some(visible AND caller), none is none(visible AND caller), both unchanged and correct. A denied related list still short-circuits to some = false, every = true, none = true (test unchanged and passing).

The existing test's comment and its assertion disagreed; both are fixed and they now agree. The invisible-draft case is asserted directly: anonymously, ada (who owns an invisible draft) and bob both satisfy { posts: { every: { published: true } } } — the draft decides nothing. every: { published: false } returns [], so the assertion is not vacuous. And with ada's own session, where the draft is visible, she drops out while bob's every is vacuously true — the empty-set case, asserted.

3. orderBy existence oracle (BLOCKING) — fixed.
The field-read gate now runs before the isRelationship refusal, matching resolveKey. Test compares the two messages the way the where path's test does, and additionally asserts the denied message does not contain "scalar columns only".

4. Docs overclaim (BLOCKING) — I closed the hole; the docs are unchanged and now true.
mergeFilters applies the same refusal, throwing the new exported UndefinedAccessFilterError when the access filter carries an undefined condition anywhere — bare, nested under an operator ({ authorId: { equals: undefined } }), or inside an AND/OR branch. So createFindMany, count, updateMany and delete are total too, and the documented guarantee holds across the whole secured surface rather than only on .where().all()/.first().

Scope call worth flagging: the refusal covers what an access rule returned, not the caller's own userFilter. undefined in a caller's where is that caller's own optional key, and the access clause can only ever narrow it — refusing there would be a much larger behaviour change and is not what the docs claim.

Blast radius re-verified: the full monorepo suite is green with the refusal in place (core 1564 passing, no config in packages/ or examples/ breaks). No follow-up issue needed for this one.

Pinned with tests in tests/access.test.ts: the anonymous shape, the nested and branch spellings, that a caller's own filter is untouched, that a boolean decision is unaffected, and that a Date/null is a condition rather than an absence.

5. Denied-list short circuit (low) — fixed.
The nested predicate is resolved before the access.kind === 'false' short circuit, so { secrets: { some: { nope: 1 } } } and { posts: { some: { nope: 1 } } } now throw identically (modulo the related list's own name, which is config either way).

6. Purity test (minor) — fixed.
purity.test.ts now walks the real import graph. Roots are every non-test file in filter/ plus secured/vocabulary.ts and secured/operators.ts; it follows runtime relative edges transitively (type-only edges are skipped — they are erased at build time and pull nothing in) and fails on any @prisma/ specifier reached. New files are covered the moment something imports them. Two guards against vacuity: the walk must visit more files than it was handed, and a negative control asserts it does detect an ORM import when pointed at contract/prisma.ts.

Note this deliberately does not match import('…') — the invariant lower.ts states is about the static module graph, and its lazy import is the sanctioned exception.

7. Changeset (minor) — updated, bump unchanged.
Kept minor for @opensaas/stack-core: every sibling changeset on prisma-8 uses minor for removals of this kind, and the line ships as one major. The body now carries an explicit removed-exports table (RELATIONSHIP_COUNT_FILTER_KEY, RelationshipCountFilterMarker, resolveRelationshipCountFilters, resolveRelationshipLabelFilters, isToOneRelationshipField, ColumnEquality, UnsupportedPredicateError) with what to do instead, the undefined-refusal behaviour change with before/after migration code, the corrected every semantics, the recursion bound, and the two newly-exported errors.


Not touched, as instructed: refinements/depth cap (#1148), select and widen-and-strip (#1149), the aggregate family (#1150), nearest() (#1151), writes (#1152/#1124). The to-many count filter shrinking to presence is unchanged and still awaiting the separate product confirmation.

Also not addressed (from your Minor list, not in scope for this pass): lower.ts caches a rejected lazy import permanently, so a transient ORM import failure poisons the process. Left alone deliberately — say the word and I'll clear pending on rejection here, or file it.

Verification: pnpm build 11/11, tsc --noEmit on core clean, pnpm lint 0 errors (2 pre-existing warnings, both untouched files), pnpm manypkg fix no changes, pnpm format clean. Tests: core 78 files / 1564 passing / 1 skipped; ui 611, cli 335, auth 298, rag 362, storage / storage-s3 / storage-vercel all passing.

@borisno2
borisno2 merged commit 715a365 into prisma-8 Sep 6, 2026
6 checks passed
@borisno2
borisno2 deleted the claude/issue-1147-where-vocabulary branch September 6, 2026 21:05
borisno2 added a commit that referenced this pull request Sep 6, 2026
…e.ts

The base moved under this branch while it was in review, so GitHub could
compute no merge ref and CI never ran for 300b17d. The one content conflict
was `packages/core/src/internal.ts`, where this branch's `where/like.ts`
re-exports and the base's `findDatabaseConnection` (#1218) were added at the
same point; both are kept.

#1220 landed the Where vocabulary from its own branch rather than adopting
this branch's engine-owned escaper, leaving a second implementation:
`containsPattern()` in `secured/vocabulary.ts`. It performed the same three
replacements in the same order with the same wrapping as `likeContainsPattern`,
so it is deleted and the `contains` case now calls the shared builder. One
escaper, as ADR-0060 requires. #1220's Where-vocabulary and filter tests pass
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant