Skip to content

Make the access-scoped include walk caller-directed - #873

Merged
borisno2 merged 3 commits into
mainfrom
claude/funny-sagan-jkqw0x
Aug 4, 2026
Merged

Make the access-scoped include walk caller-directed#873
borisno2 merged 3 commits into
mainfrom
claude/funny-sagan-jkqw0x

Conversation

@borisno2

@borisno2 borisno2 commented Aug 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Replaces buildIncludeWithAccessControl + mergeIncludeWithAccessControl (build the full access-scoped tree for every relationship, then reconcile against whatever the caller asked for) with a single caller-directed buildAccessScopedInclude that walks only the branches a request names.
  • Naming a relation in an include now fetches only that relation's own columns and stops, at every level — not just the root (the "One hop" rule, completing ADR-0024). Reaching further means naming further: include: { author: { include: { organization: true } } }.
  • A relation nobody named (caller include, fragment query, or a field's folded needs) never has its list's operation-level query access evaluated at all — the wasted-access-calls problem the issue describes.
  • foldDeclaredDependencies (needs, ADR-0025) now recursively folds a field's declared dependencies beneath any reached relation (declaration-added or caller-named, bare or not), since the old free ride via auto-expansion no longer exists. Its own visitedLists cycle guard is the re-pointed version of the old relationship-graph walk's guard — defense in depth behind needs-closure.ts's generate-time validation, which is the primary backstop against a cyclic needs closure.
  • READ_INCLUDE_MAX_DEPTH / AccessScopeDepthExceededError keep their value, type, and throw sites — fail-closed per ADR-0022 — but the cap is now a cost limit on a caller-directed request rather than a safety boundary on an engine-generated walk, and the error message wording was reworded accordingly.

Why

Follow-up from #848 / ADR-0024, filed as ADR-0026. After #848, a bare read (no include) stopped auto-including every relation — but a caller include as small as { author: true } still triggered a full walk of every other relation on the list, evaluating every related list's query access even for relations the caller never asked about, and auto-expanded the named relation's own subtree to the depth cap.

Detection / migration (silent break)

An include that named a relation bare and read past it — item.<named>[0].<unnamed> — now gets undefined for the unnamed part, with no error. See the changeset for the grep and the fix (name the deeper relation explicitly).

Test plan

Closes #852


Generated by Claude Code

Naming a relation in an include now fetches only that relation's own
columns and stops, at every level (not just the root) — the "one hop"
rule that completes ADR-0024. buildAccessScopedInclude replaces the
old build-full-tree-then-merge pair (buildIncludeWithAccessControl +
mergeIncludeWithAccessControl) with a single caller-directed walk that
only evaluates a related list's query access for relations a request
actually names. foldDeclaredDependencies now recursively folds a
field's needs beneath any reached relation, since the old free ride
via auto-expansion no longer exists, using its own cycle guard
(re-pointed from the retired relationship-graph walk) as a defensive
backstop to needs-closure.ts's generate-time validation.

Closes #852

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MFBtkhz5Eed9usmnVck79b
@changeset-bot

changeset-bot Bot commented Aug 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 01b8cd4

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-auth Minor
@opensaas/stack-cli Minor
@opensaas/stack-rag Minor
@opensaas/stack-storage Minor
@opensaas/stack-tiptap Minor
@opensaas/stack-ui 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

@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
stack-docs Ready Ready Preview Aug 2, 2026 10:15am

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🟢 Lines 93.32% (🎯 65%) 1231 / 1319
🟢 Statements 91.77% (🎯 65%) 1328 / 1447
🟢 Functions 98.12% (🎯 62%) 209 / 213
🟢 Branches 83.31% (🎯 50%) 894 / 1073
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/access/access-filter.ts 98.41% 96.55% 100% 100% 137
packages/core/src/access/declared-dependencies.ts 95.34% 95.65% 100% 100% 149, 159
packages/core/src/access/depth-limits.ts 100% 100% 100% 100%
packages/core/src/access/errors.ts 100% 100% 100% 100%
packages/core/src/access/field-visibility.ts 95.94% 92.59% 100% 95.71% 297, 337-338
packages/core/src/access/relationship-count.ts 86.53% 78.78% 100% 94.11% 68, 70, 109, 116, 124, 140, 145, 147, 159, 183, 191, 212, 257, 309
packages/core/src/query/relationship-options.ts 100% 100% 100% 100%
Generated in workflow #1593 for commit 01b8cd4 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 76.72% 244 / 318
🔵 Statements 76.29% 251 / 329
🔵 Functions 69.15% 74 / 107
🔵 Branches 64.25% 160 / 249
File CoverageNo changed files found.
Generated in workflow #1593 for commit 01b8cd4 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 79.16% 1539 / 1944
🔵 Statements 78.86% 1601 / 2030
🔵 Functions 85.94% 214 / 249
🔵 Branches 67.84% 690 / 1017
File CoverageNo changed files found.
Generated in workflow #1593 for commit 01b8cd4 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 98.33% 118 / 120
🔵 Statements 98.37% 121 / 123
🔵 Functions 100% 38 / 38
🔵 Branches 94.44% 85 / 90
File CoverageNo changed files found.
Generated in workflow #1593 for commit 01b8cd4 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 78.57% 220 / 280
🔵 Statements 80.06% 245 / 306
🔵 Functions 86.07% 68 / 79
🔵 Branches 75.88% 214 / 282
File CoverageNo changed files found.
Generated in workflow #1593 for commit 01b8cd4 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 47.97% 355 / 740
🔵 Statements 48.14% 377 / 783
🔵 Functions 54.26% 70 / 129
🔵 Branches 42.55% 180 / 423
File CoverageNo changed files found.
Generated in workflow #1593 for commit 01b8cd4 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 2, 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 #1593 for commit 01b8cd4 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 2, 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 #1593 for commit 01b8cd4 by the Vitest Coverage Report Action

…dded edges

`foldDeclaredDependencies` applied its `visitedLists` cycle guard to every
edge it walked, including relations the request itself named. A request is a
finite literal and cannot loop, so the guard stopped the fold at any path
that merely revisits a list — `Post → author → posts`, or a self-referential
`parent` — leaving the revisited list's own `needs` unsatisfied and its
computed fields resolving over `undefined`, which is the failure ADR-0025
exists to prevent "at every level a field is computed".

The guard now applies only to declaration-added edges, which is where the
unbounded recursion actually comes from: a branch this fold adds carries no
caller include of its own, so everything beneath it is declaration-added too
and each such edge either reaches a list not yet on the path or stops,
bounding that suffix by the number of lists. A caller-named edge is bounded
by the request's own literal instead. The two-list mutual-`needs` cycle still
terminates with an identical include.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTktGvFM8QztXEDr265GHJ

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Human review recommended

It changes core access-control include scoping semantics and runtime behavior in security-sensitive code paths, warranting final human review despite strong test coverage.

Pull request overview

This PR refactors read-side access scoping to be caller-directed: access-scoped include trees are now built only along branches explicitly named by the request (caller include, fragment query, or needs-folded dependencies). It also completes the “One hop” rule (ADR-0026), where naming a relation fetches only that relation’s own columns unless deeper relations are explicitly included.

Changes:

  • Replace the previous “auto-walk then merge” include scoping (buildIncludeWithAccessControl + mergeIncludeWithAccessControl) with buildAccessScopedInclude, which scopes only requested branches.
  • Update foldDeclaredDependencies to recursively fold needs beneath any reached relation, with a defensive cycle guard re-pointed to the declaration fold.
  • Update tests, docs, and changesets to reflect caller-directed include behavior and depth-cap messaging semantics.
File summaries
File Description
packages/core/src/context/index.ts Switch read include resolution to buildAccessScopedInclude and pass listKey into foldDeclaredDependencies.
packages/core/src/access/access-filter.ts Introduce caller-directed buildAccessScopedInclude and remove legacy auto-walk/merge machinery.
packages/core/src/access/index.ts Update access module exports to expose the new include scoping API.
packages/core/src/access/declared-dependencies.ts Make needs folding recursive under reached relations; add cycle-guarded recursion inputs.
packages/core/src/access/declared-dependencies.test.ts Add unit tests for recursive needs folding and defensive cycle termination.
packages/core/src/access/errors.ts Reword AccessScopeDepthExceededError messaging to describe a cost refusal.
packages/core/src/access/depth-limits.ts Reframe READ_INCLUDE_MAX_DEPTH as a cost limit under caller-directed walking.
packages/core/src/access/field-visibility.ts Update references/comments to the new access filter function name.
packages/core/src/access/relationship-count.ts Update docs/comments to reference buildAccessScopedInclude.
packages/core/src/query/relationship-options.ts Update comment to reflect that access-scoped include has nothing to scope in this path.
packages/core/src/access/access-filter.test.ts Rewrite tests to assert ADR-0026 “one hop” behavior, depth-cap semantics, and no wasted access calls.
packages/core/tests/context.test.ts Add integration coverage ensuring unrequested relations don’t invoke query access (#852).
packages/core/tests/resolve-chain.test.ts Update concurrency regression test to reflect explicit nested include requirements (one-hop).
packages/core/tests/needs-declared-dependencies.test.ts Adjust cycle-guard test narrative to match new declaration-fold guard semantics.
packages/core/tests/access-relationships.test.ts Update tests to use buildAccessScopedInclude instead of legacy APIs.
packages/core/CLAUDE.md Document ADR-0026 “One hop” rule and caller-directed include scoping.
docs/content/concepts/queries.md Document one-hop include behavior in user-facing queries docs.
docs/content/concepts/access-control.md Document caller-directed include scoping and cost-limit framing for depth cap.
.changeset/one-hop-scopes-relations.md Minor changeset documenting one-hop behavior + migration note + depth error messaging update.
.changeset/gentle-otters-fold.md Patch changeset for the revisited-list needs folding fix.
Review details
  • Files reviewed: 20/20 changed files
  • Comments generated: 2
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment on lines +88 to +95
/** The explicit nested `include` on an include entry, if the entry is a structured object naming one. */
function getExplicitInclude(value: unknown): Record<string, unknown> | undefined {
if (value && typeof value === 'object' && 'include' in value) {
const include = (value as { include?: unknown }).include
return include && typeof include === 'object' ? (include as Record<string, unknown>) : undefined
}
return undefined
}
Comment on lines 36 to 40
@@ -194,9 +40,7 @@ type IncludeEntryObject = { where?: PrismaFilter; include?: IncludeObject; take?
*
Addresses Copilot review feedback on #873: asEntryObject and
getExplicitInclude both narrowed an unknown value straight to
Record<string, unknown> via `as`, which would (in principle) admit an
array or a `null`-adjacent object as a valid include entry. Both now
go through a shared isPlainObject type guard first, so no field is
read off a value TypeScript hasn't actually narrowed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MFBtkhz5Eed9usmnVck79b
@borisno2
borisno2 merged commit b21d8b2 into main Aug 4, 2026
6 checks passed
@borisno2
borisno2 deleted the claude/funny-sagan-jkqw0x branch August 4, 2026 10:10
@github-actions github-actions Bot mentioned this pull request Aug 4, 2026
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.

Make the access-scoped include walk caller-directed

3 participants