Problem
ScopedStack.canAccessFile() (packages/core/src/stack.ts:1829) decides file access partly by "can the requester read any record that references this fileId." It does so with:
const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 });
if (refResult.records.length > 0) return true;
this.query is ScopedStack.query, which filters-then-refills but caps at maxFetched = limit * 10 and returns a cursor rather than exhausting (stack.ts:1988-2010). With limit: 1 that cap is 10 underlying records. So only the first ~10 records referencing the file (in default sort order) are ever canRead-checked. If those ten are unreadable by the requester but an eleventh referencing record is readable, canAccessFile wrongly returns false.
Real-world trigger: a widely-referenced file — a group header image, a shared logo, an avatar reused across many records — attached to many records the requester can't read plus one they can. Result: a false StackPermissionError on getAttachment() (stack.ts:2156) and on legitimate reference creation via the #51 gates (requireAssociationAccess/requireFileRefAccess). This is the "treating one query() call as exhaustive is a caller bug" rule from #50 — violated inside the permission predicate itself.
Fix
Scan referencing records to exhaustion but short-circuit on the first readable one, rather than relying on ScopedStack.query's bounded refill. Iterate pages of the unscoped stack.query({ filter: { attachmentFileId: fileId } }) and canRead() each (reusing a one-time grant prefetch, as ScopedStack.query already does, so per-record checks stay cheap), returning true on the first pass and false only after genuine exhaustion. Bound the walk with the existing QUERY_ALL_MAX safety cap so a pathologically-referenced file surfaces a StackQueryError rather than scanning unboundedly. Keep the current includeDeleted: false scope unless we deliberately decide soft-deleted referents should convey download access (out of scope here — note only).
The same bounded-scan-treated-as-exhaustive bug class appears in ensureOwnerEntity() (assessment §E2, duplicate owner cards past ~50 _entity records); consider a shared "first record matching predicate, walked to exhaustion" helper and fixing both together.
Tests
Refs
#50 (exhaustive-pagination-is-the-caller's-job), #51 (the gates that consume canAccessFile). From docs/design-assessment-2026-07.md §A3 (and §E2 for the sibling case) (PR #105).
Problem
ScopedStack.canAccessFile()(packages/core/src/stack.ts:1829) decides file access partly by "can the requester read any record that references this fileId." It does so with:this.queryisScopedStack.query, which filters-then-refills but caps atmaxFetched = limit * 10and returns a cursor rather than exhausting (stack.ts:1988-2010). Withlimit: 1that cap is 10 underlying records. So only the first ~10 records referencing the file (in default sort order) are evercanRead-checked. If those ten are unreadable by the requester but an eleventh referencing record is readable,canAccessFilewrongly returns false.Real-world trigger: a widely-referenced file — a group header image, a shared logo, an avatar reused across many records — attached to many records the requester can't read plus one they can. Result: a false
StackPermissionErrorongetAttachment()(stack.ts:2156) and on legitimate reference creation via the #51 gates (requireAssociationAccess/requireFileRefAccess). This is the "treating onequery()call as exhaustive is a caller bug" rule from #50 — violated inside the permission predicate itself.Fix
Scan referencing records to exhaustion but short-circuit on the first readable one, rather than relying on
ScopedStack.query's bounded refill. Iterate pages of the unscopedstack.query({ filter: { attachmentFileId: fileId } })andcanRead()each (reusing a one-time grant prefetch, asScopedStack.queryalready does, so per-record checks stay cheap), returningtrueon the first pass andfalseonly after genuine exhaustion. Bound the walk with the existingQUERY_ALL_MAXsafety cap so a pathologically-referenced file surfaces aStackQueryErrorrather than scanning unboundedly. Keep the currentincludeDeleted: falsescope unless we deliberately decide soft-deleted referents should convey download access (out of scope here — note only).The same bounded-scan-treated-as-exhaustive bug class appears in
ensureOwnerEntity()(assessment §E2, duplicate owner cards past ~50_entityrecords); consider a shared "first record matching predicate, walked to exhaustion" helper and fixing both together.Tests
getAttachment()succeeds (regression)associatean attachment / write a file-ref field) succeeds under the same >10-referent shapeStackPermissionError(no false positive introduced)Refs
#50 (exhaustive-pagination-is-the-caller's-job), #51 (the gates that consume
canAccessFile). Fromdocs/design-assessment-2026-07.md§A3 (and §E2 for the sibling case) (PR #105).