Skip to content

Commit 463b50a

Browse files
committed
fix(knowledge): read result metadata under the scope the results were read under
Retrieval reports the scope its content was read under — with the live grants it resolved, when a gated source's candidate was read — and the metadata lookup applies the full read predicate under that scope. A gated source's result keeps its name and URL; a revoked one loses them here as it did at hydration.
1 parent abf3989 commit 463b50a

6 files changed

Lines changed: 31 additions & 14 deletions

File tree

‎apps/sim/app/api/v1/knowledge/search/route.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,12 @@ vi.mock('@/lib/knowledge/access/availability', () => ({
5454
}))
5555

5656
vi.mock('@/lib/knowledge/search/queries', () => ({
57-
executeKnowledgeSearch: mockExecuteKnowledgeSearch,
57+
/** The route reads the retrieval result; the rows come from the same mock the tests drive. */
58+
retrieveKnowledgeSearch: async (params: { access: unknown }) => ({
59+
rows: await mockExecuteKnowledgeSearch(params),
60+
retrieval: { status: 'complete', timedOutLegs: [] },
61+
readAccess: params.access,
62+
}),
5863
getDocumentMetadataByIds: mockGetDocumentMetadataByIds,
5964
}))
6065

‎apps/sim/app/api/v1/knowledge/search/route.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import {
1616
} from '@/lib/knowledge/embeddings'
1717
import { resolveKnowledgeSearchDefaults } from '@/lib/knowledge/search/defaults'
1818
import {
19-
executeKnowledgeSearch,
2019
getDocumentMetadataByIds,
20+
type KnowledgeRetrievalResult,
21+
retrieveKnowledgeSearch,
2122
type SearchResult,
2223
} from '@/lib/knowledge/search/queries'
2324
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
@@ -226,7 +227,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
226227
}
227228
: undefined
228229

229-
let results: SearchResult[]
230+
let retrieved: KnowledgeRetrievalResult
230231
let queryEmbeddingIsBYOK: boolean | null = null
231232
const [readAccess, { searchMode, boostRecency }] = await Promise.all([
232233
resolveV1KnowledgeReadAccess(userId, rateLimit, workspaceId),
@@ -242,7 +243,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
242243
const access = 'get' in readAccess ? await readAccess.get() : readAccess
243244

244245
if (!hasQuery && hasFilters) {
245-
results = await executeKnowledgeSearch({
246+
retrieved = await retrieveKnowledgeSearch({
246247
knowledgeBaseIds: accessibleKbIds,
247248
topK,
248249
access,
@@ -258,7 +259,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
258259
workspaceId
259260
)
260261
queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK
261-
results = await executeKnowledgeSearch({
262+
retrieved = await retrieveKnowledgeSearch({
262263
knowledgeBaseIds: accessibleKbIds,
263264
topK,
264265
access,
@@ -311,8 +312,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
311312
tagDefinitionsMap[kbId] = map
312313
})
313314

315+
const results = retrieved.rows
314316
const documentIds = results.map((r) => r.documentId)
315-
const documentMetadataMap = await getDocumentMetadataByIds(documentIds, access)
317+
const documentMetadataMap = await getDocumentMetadataByIds(documentIds, retrieved.readAccess)
316318
const readableResults = results.filter((result) => documentMetadataMap[result.documentId])
317319

318320
return NextResponse.json({

‎apps/sim/lib/knowledge/application/search.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ vi.mock('@/lib/knowledge/search/queries', () => ({
9696
retrieveKnowledgeSearch: async (...args: unknown[]) => ({
9797
rows: await mocks.executeSearch(...args),
9898
retrieval: mocks.retrieval(),
99+
readAccess: (args[0] as { access: unknown }).access,
99100
}),
100101
getDocumentMetadataByIds: mocks.getDocumentMetadata,
101102
}))

‎apps/sim/lib/knowledge/application/search.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -625,7 +625,7 @@ const searchKnowledgeUseCase = defineAuthorizedKnowledgeUseCase({
625625
const basicDocumentMetadata = await measureSearchStage('metadata', () =>
626626
getDocumentMetadataByIds(
627627
rows.map((row) => row.documentId),
628-
access
628+
retrieved.readAccess
629629
)
630630
)
631631
const results = rows

‎apps/sim/lib/knowledge/search/queries.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,7 @@ describe('workspace-scoped vector retrieval', () => {
647647
).toEqual({
648648
rows: [],
649649
retrieval: { status: 'partial', timedOutLegs: ['vector'] },
650+
readAccess: params.access,
650651
})
651652
}
652653
)
@@ -743,6 +744,7 @@ describe('workspace-scoped vector retrieval', () => {
743744
expect(result).toEqual({
744745
rows: [],
745746
retrieval: { status: 'partial', timedOutLegs: ['vector'] },
747+
readAccess: params.access,
746748
})
747749
}
748750
for (const resume of release) resume()

‎apps/sim/lib/knowledge/search/queries.ts‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,11 @@ export interface DocumentMetadata {
174174
}
175175

176176
/**
177-
* Batch-fetch display metadata for documents referenced by search results.
178-
* Applies the same visibility and access predicates as the search SQL itself,
179-
* so the lookup never surfaces a filename for a row the caller could not have
180-
* matched. Returns a map keyed by document id; missing ids indicate the
181-
* document is no longer visible and should be skipped.
177+
* Batch-fetch display metadata for documents referenced by search results, under the full read
178+
* predicate and the scope the results were read under — with the live grants that scope resolved,
179+
* so a gated source's result keeps its name and URL, and a revoked one loses them here too.
180+
* Returns a map keyed by document id; missing ids indicate the document is no longer visible and
181+
* should be skipped.
182182
*/
183183
export async function getDocumentMetadataByIds(
184184
documentIds: string[],
@@ -206,7 +206,7 @@ export async function getDocumentMetadataByIds(
206206
eq(document.userExcluded, false),
207207
isNull(document.archivedAt),
208208
isNull(document.deletedAt),
209-
knowledgeMetadataCandidateAccessCondition(access)
209+
knowledgeAccessCondition(access)
210210
)
211211
)
212212
)
@@ -598,6 +598,8 @@ export interface LiveSourceAccess {
598598
gates: (connectorId: string) => boolean
599599
/** The caller's scope with its grants, and the gated sources those grants do not cover. */
600600
resolve: () => Promise<{ access: KnowledgeAccessScope; denied: ReadonlySet<string> }>
601+
/** The scope content was read under: with its grants once they were resolved, else as given. */
602+
current: () => Promise<KnowledgeAccessScope>
601603
}
602604

603605
/** Binds a search's gated sources to one memoized resolution of the caller's grants. */
@@ -619,6 +621,7 @@ export function liveSourceAccessFor(
619621
}
620622
return {
621623
gates: (connectorId) => gated.has(connectorId),
624+
current: async () => (pending ? (await pending).access : access),
622625
resolve: () => {
623626
pending ??= measureSearchStage('live_source_grants', async () => {
624627
const scopes = await mapWithConcurrency(pages, SOURCE_RANKING_CONCURRENCY, (page) =>
@@ -2055,6 +2058,8 @@ export interface RetrievalStatus {
20552058
export interface KnowledgeRetrievalResult {
20562059
rows: SearchResult[]
20572060
retrieval: RetrievalStatus
2061+
/** The scope the returned content was read under; what may see these rows may see their metadata. */
2062+
readAccess: KnowledgeAccessScope
20582063
}
20592064

20602065
/** Legacy surfaces cannot silently present partial retrieval as complete. */
@@ -2090,14 +2095,16 @@ export async function retrieveKnowledgeSearch(
20902095
keyword: new SearchBudget('keyword', deadline, params.signal),
20912096
tags: new SearchBudget('tags', deadline, params.signal),
20922097
}
2093-
const finish = (rows: SearchResult[]): KnowledgeRetrievalResult => {
2098+
const finish = async (rows: SearchResult[]): Promise<KnowledgeRetrievalResult> => {
20942099
params.signal?.throwIfAborted()
2100+
const readAccess = (await liveSourceAccess?.current()) ?? access
20952101
const timedOutLegs = Object.values(budgets)
20962102
.filter((budget) => budget.timedOut)
20972103
.map((budget) => budget.leg)
20982104
return {
20992105
rows: boostRecency ? applyRecencyBoost(rows) : rows,
21002106
retrieval: { status: timedOutLegs.length ? 'partial' : 'complete', timedOutLegs },
2107+
readAccess,
21012108
}
21022109
}
21032110
/**

0 commit comments

Comments
 (0)