Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ A check that gates whether a session may read or write a single field, returning
_Avoid_: column access, property access

**Access Filter** (pre-query phase):
The first pass of a read, run before the database is hit. Uses operation-level access to build the access-scoped `include`/`where` so the database only returns rows and relations the session is allowed to see. Failing to compute a scope is a **denial**, never a passthrough: a caller-supplied `include` nested deeper than the phase can scope throws rather than returning unscoped rows (ADR-0022). This is distinct from having nothing to scope — a list with no relationships — which passes through unchanged.
The first pass of a read, run before the database is hit. Uses operation-level access to build the access-scoped `include`/`where` so the database only returns rows and relations the session is allowed to see. It scopes the relations a read asked for; it does not choose them (see Bare read). Failing to compute a scope is a **denial**, never a passthrough: a caller-supplied `include` nested deeper than the phase can scope throws rather than returning unscoped rows (ADR-0022). This is distinct from having nothing to scope — a list with no relationships — which passes through unchanged.
_Avoid_: query builder, include builder

**Field Visibility** (post-query phase):
Expand All @@ -26,6 +26,10 @@ _Avoid_: result filter, output filter, field stripper
The sequence of `resolveOutput` hooks a read has entered on the way to a value — a hook that issues its own read extends the chain with that read's hooks. A top-level read starts an empty chain, and each hook's chain is its own: two hooks running concurrently never see each other's. A chain may not repeat a (list, field) pair, because a hook that re-enters itself cannot terminate; that is refused loudly. Chain length is bounded separately, as a cost limit only.
_Avoid_: hook chain (that is plugins composing hooks), resolveOutput depth, recursion depth

**Bare read**:
A read that names no relations — no `include`, no fragment `query`. It returns the row's own columns and its virtual fields, never its relations, matching what the underlying ORM does with the same call (ADR-0024). The rule holds for every read: single, many, and singleton, whether or not access control is being bypassed. Related data is always something a read asks for, so a call site's shape is readable from the call site.
_Avoid_: auto-include, default include, unqualified read

**Silent failure**:
The convention that an access-denied operation returns `null` (single) or `[]` (many) rather than throwing, so callers cannot distinguish "denied" from "does not exist".
_Avoid_: access error, permission error
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# A read with no `include` fetches scalars, not relations

A `context.db` read with no `include` auto-included every readable relationship of the list and recursed to `READ_INCLUDE_MAX_DEPTH`, where Prisma's semantics for the same call are "the row's own columns". We decided a bare read returns **the row's own columns plus its virtual fields, and no relations** — matching Prisma — so relations are fetched only when a caller names them in an `include` or projects them through a fragment `query`. The auto-include walk stays, because a caller-supplied `include` still has to be scoped against it; what changes is that nothing reaches it unprompted.

The default was not a coherent property of the read API by the time we removed it. A **sudo** bare read already returned scalars only (the sudo branch passes the caller's `include` through, and there isn't one), while the same call on a session context returned a five-level relation tree — and the singleton read auto-included unconditionally, with no caller-`include` plumbing at all. Three read paths, three shapes, differing by privilege rather than by anything the caller wrote. The flip collapses them into one rule with no exceptions.

The convenience the default bought has also expired. It existed so admin surfaces could render an Item label off `item.relation` without knowing the relation graph; both the list view and the item view now build explicit includes (to-one relations for labels, bounded to-many sections, filtered `_count`s), so the admin UI does not depend on it. The one first-party consumer still relying on a bare read was the MCP `query` tool, where a five-deep tree is actively harmful — every byte lands in a context window — and where foreign-key columns survive the flip, so an assistant can still resolve a relation by id in a follow-up call.

We also think this default is the shared root cause behind a run of separately-fixed symptoms — a caller `include` replacing rather than merging with the access-controlled one (#566), access failing open past the depth cap (#830), a stack overflow re-descending a cyclic relationship graph (0.27.1), and an unbounded resolve chain (#844) — each of which was reachable only because there was an auto-include to replace, to cap, or to feed rows into a hook. Bounding the consequences was right in each case and none of it is reverted here; the point is that with an explicit tree, the depth cap and the cycle prune stop being the last line of defence and become ordinary cost limits over something the caller wrote.

## Considered Options

- **Keep the default, document the fragment `query` API** as the supported way to project: the honest do-nothing, and fragments genuinely are more expressive than the `include: {}` workaround. Rejected because it leaves every bare read in every app paying for a relation tree it did not ask for, and leaves the cycle class reachable from ordinary application code.
- **Keep the default with an opt-out** — per read (`autoInclude: false`), per list, or a global back-compat flag. Rejected: any opt-in keeps the auto-include walk on the supported surface, so its depth cap, cycle prune, and merge path must be maintained and tested in both modes indefinitely. A per-list flag is the worst of these, because the answer to "what does a bare read return?" would vary by list and you could no longer read a call site and know its shape.
- **Suppress auto-include only inside `resolveOutput`** (the narrowest fix for the reported cycle): rejected. It is close to a straight revert of ADR-0022's first-hop scoping, which ADR-0023 already declined once — "a hang is not worth trading for a silent leak" — and it fixes one symptom while leaving bare reads over-fetching everywhere else.
- **Making the walk caller-directed in the same change** (scope only the branches the caller asked for, rather than walking every relation and merging afterwards): deferred, not rejected. It is the logical completion of this decision, but it rewrites the code path carrying the #566 and #830 security fixes; landing a breaking default change and that rewrite together means a bisect cannot tell you which one broke you. Tracked separately.
- **Holding the change for 1.0**: rejected. Every release between now and then accumulates applications written against a default that will break later, so shipping now lands the breakage on the smallest population it will ever affect.

## Consequences

- **This break is silent, which makes it more dangerous than a louder one.** A read that returned `post.author` now returns no `author` key — no error, no warning, just less data. This is the opposite of ADR-0022's breakage, which announced itself by throwing. The changeset and migration note therefore lead with detection rather than with rationale: find `context.db.*.find*` calls with no `include`, and virtual `resolveOutput` hooks that read `item.<relation>`.
- **A virtual field that reads a relation off `item` silently degrades**, and we accept that rather than solving it here. Such a hook should read through `context.db`, or its caller should pass an `include`. Declared relation dependencies (`virtual({ needs: [...] })`) would fix the shape properly and are tracked separately; folding a new field-config option into a breaking change would mean defending two decisions at once.
- **Bare reads stop evaluating operation-level `query` access on related lists.** The walk ran those user-supplied functions — which may themselves hit the database — for every relation at every level before fetching anything. Skipping it is a larger saving than the SQL, and it means an access function relied upon for a side effect will no longer fire on a bare read. Access functions were never a side-effect surface, but the change is observable.
- **ADR-0022 and ADR-0023 stand unchanged.** Both record what the engine does when it _cannot_ scope something a caller asked for; neither said anything about what a read with no `include` should fetch in the first place, which is the gap this fills. `READ_INCLUDE_MAX_DEPTH` and the resolve-chain cycle guard both remain, now bounding only explicitly-requested trees.
- The singleton read gains caller-`include` handling it never had, so a singleton read can now be narrowed or widened like any other read.
Loading