Skip to content

Commit 4021546

Browse files
committed
improvement(search): shorter live document IDs
Live search now mints live2: IDs: readable, ~-separated, percent-escaped provider fields with no reader or scope, about a quarter the length and tokens of the base64 JSON form. Reads derive the reader and scope from the request; account resolution and provider verification remain the authorization. Earlier live: IDs still read with their reader and scope checks. Document ID filters, Assistant scope intersection, and citation IDs compare by the decoded document identity, so both forms of one document match.
1 parent e999cf5 commit 4021546

10 files changed

Lines changed: 425 additions & 86 deletions

File tree

‎apps/sim/lib/knowledge/__integration__/gitlab-federated.integration.ts‎

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,8 @@ import {
3131
updateKnowledgeConnector,
3232
} from '@/lib/knowledge/application/connectors'
3333
import { refreshConnectorDirectory } from '@/lib/knowledge/connectors/external-group-sync'
34-
import {
35-
decodeLiveReference,
36-
encodeLiveReference,
37-
readLiveDocument,
38-
searchLiveKnowledge,
39-
} from '@/lib/sim-search/live/application'
34+
import { readLiveDocument, searchLiveKnowledge } from '@/lib/sim-search/live/application'
35+
import { decodeLiveReference } from '@/lib/sim-search/live/reference'
4036
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
4137

4238
interface FixturePerson {
@@ -132,10 +128,6 @@ describe.skipIf(!fixtureFile)('federated self-hosted GitLab Search', () => {
132128
})
133129
}
134130

135-
function documentForReader(documentId: string, person: FixturePerson) {
136-
return encodeLiveReference({ ...decodeLiveReference(documentId), user: person.simId })
137-
}
138-
139131
beforeAll(async () => {
140132
const fixture: unknown = JSON.parse(await readFile(fixtureFile!, 'utf8'))
141133
if (
@@ -318,13 +310,11 @@ describe.skipIf(!fixtureFile)('federated self-hosted GitLab Search', () => {
318310
120_000
319311
)
320312

321-
it('keeps references reader-bound and rechecks confidentiality before reads', async () => {
313+
it('checks a shared source reference against its reader and rechecks confidentiality', async () => {
322314
const reporter = await search(people.reporter!, 'code')
323315
const file = reporter.results[0]!
316+
/** The ID names no reader: the guest's own GitLab access refuses the reporter's code result. */
324317
await expect(read(people.guest!, file.documentId)).rejects.toMatchObject({ code: 'not_found' })
325-
await expect(
326-
read(people.guest!, documentForReader(file.documentId, people.guest!))
327-
).rejects.toBeDefined()
328318
const guest = await search(people.guest!, 'issues')
329319
const normal = guest.results.find(
330320
(row) => decodeLiveReference(row.documentId).id === String(issueIid)

‎apps/sim/lib/knowledge/mcp/server.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ vi.mock('@/lib/knowledge/application/chat', () => ({
5555

5656
import { OrchestrationError } from '@/lib/core/orchestration/types'
5757
import { createKnowledgeMcpServer } from '@/lib/knowledge/mcp/server'
58+
import { encodeLiveReference } from '@/lib/sim-search/live/reference'
5859
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
5960

6061
const mocks = hoisted
@@ -301,6 +302,20 @@ describe('batched document reads', () => {
301302
expect(mocks.liveRead).toHaveBeenCalledTimes(2)
302303
})
303304

305+
it('reads a document once when the batch names it in both ID forms', async () => {
306+
create()
307+
mocks.liveRead.mockImplementation(async ({ input }: { input: { documentId: string } }) =>
308+
readResult(input.documentId)
309+
)
310+
const reference = { provider: 'google_drive', account: 'account', id: 'doc' } as const
311+
const earlier = `live:${Buffer.from(
312+
JSON.stringify({ v: 1, user: 'person-1', scope: 'organization:org-1', ...reference })
313+
).toString('base64url')}`
314+
await call('read_document', { documentIds: [earlier, encodeLiveReference(reference)] })
315+
expect(mocks.rateLimit).toHaveBeenCalledWith(request, auth, expect.anything(), 1)
316+
expect(mocks.liveRead).toHaveBeenCalledTimes(1)
317+
})
318+
304319
it('reads at most five documents from providers at once, in request order', async () => {
305320
create()
306321
let inFlight = 0

‎apps/sim/lib/knowledge/mcp/server.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { liveCitationId } from '@/lib/knowledge/search/citation'
3030
import { toolError } from '@/lib/mcp/tool-result'
3131
import { readLiveDocument, searchLiveKnowledge } from '@/lib/sim-search/live/application'
3232
import { LiveReadError } from '@/lib/sim-search/live/read-error'
33+
import { liveDocumentKey } from '@/lib/sim-search/live/reference'
3334
import { v2CaughtOrchestrationError } from '@/app/api/v2/lib/response'
3435
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
3536

@@ -225,7 +226,9 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
225226
async (raw: unknown, extra: { signal: AbortSignal }) => {
226227
const parsed = readLiveDocumentMcpSchema.safeParse(raw)
227228
const batch =
228-
parsed.success && parsed.data.documentIds ? [...new Set(parsed.data.documentIds)] : []
229+
parsed.success && parsed.data.documentIds
230+
? [...new Map(parsed.data.documentIds.map((id) => [liveDocumentKey(id), id])).values()]
231+
: []
229232
return execute(
230233
'read_document',
231234
knowledgeOperations.readDocument,
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { sha256Hex } from '@sim/security/hash'
2+
import { liveDocumentKey } from '@/lib/sim-search/live/reference'
23

3-
/** Stable opaque citation IDs keep the model from rewriting long live references. */
4+
/**
5+
* Stable opaque citation IDs keep the model from rewriting long live references. The hash covers
6+
* the document's identity rather than the ID's spelling, so an earlier-form ID and a current one
7+
* for the same document cite as one source. Saved chats keep the citation IDs their tool results
8+
* stored, so changing what is hashed never strands an existing citation.
9+
*/
410
export function liveCitationId(documentId: string): string {
5-
return `live:${sha256Hex(documentId).slice(0, 32)}`
11+
return `live:${sha256Hex(liveDocumentKey(documentId)).slice(0, 32)}`
612
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
import { describe, expect, it } from 'vitest'
2+
import { liveCitationId } from '@/lib/knowledge/search/citation'
23
import { intersectWorkspaceSearchFilters } from '@/lib/knowledge/search/filters'
4+
import { encodeLiveReference } from '@/lib/sim-search/live/reference'
5+
6+
const fields = {
7+
account: 'account',
8+
provider: 'slack' as const,
9+
id: '1730912345.123456',
10+
container: 'C1',
11+
}
12+
/** One Slack message in the current ID form and in the earlier form older conversations hold. */
13+
const currentId = encodeLiveReference(fields)
14+
const earlierId = `live:${Buffer.from(
15+
JSON.stringify({ v: 1, user: 'reader', scope: 'workspace:w', ...fields })
16+
).toString('base64url')}`
317

418
describe('Assistant search scope', () => {
519
it('keeps the narrower end of a date window and refuses an empty one', () => {
@@ -28,4 +42,22 @@ describe('Assistant search scope', () => {
2842
intersectWorkspaceSearchFilters({ documentIds: ['a'] }, { documentIds: ['b'] })
2943
).toThrow('outside this search')
3044
})
45+
46+
it('keeps a requested document the user selected in the other ID form', () => {
47+
expect(
48+
intersectWorkspaceSearchFilters({ documentIds: [currentId] }, { documentIds: [earlierId] })
49+
).toEqual({ documentIds: [currentId] })
50+
expect(
51+
intersectWorkspaceSearchFilters({ documentIds: [earlierId] }, { documentIds: [currentId] })
52+
).toEqual({ documentIds: [earlierId] })
53+
})
54+
})
55+
56+
describe('live citation IDs', () => {
57+
it('cite one document as one source whichever ID form named it', () => {
58+
expect(liveCitationId(earlierId)).toBe(liveCitationId(currentId))
59+
expect(liveCitationId(currentId)).not.toBe(
60+
liveCitationId(encodeLiveReference({ ...fields, id: '1730912345.123457' }))
61+
)
62+
})
3163
})

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { OrchestrationError } from '@/lib/core/orchestration/types'
2+
import { includesLiveDocument } from '@/lib/sim-search/live/reference'
23

34
/** Document constraints shared by Search and Assistant, applied before ranking. */
45
export interface WorkspaceSearchFilters {
@@ -19,10 +20,11 @@ export function intersectWorkspaceSearchFilters(
1920
if (scope.source && requested.source && scope.source !== requested.source) {
2021
throw new OrchestrationError('validation', 'The requested source is outside this search')
2122
}
22-
const documentIds = scope.documentIds
23+
const scopeDocumentIds = scope.documentIds
24+
const documentIds = scopeDocumentIds
2325
? requested.documentIds
24-
? requested.documentIds.filter((id) => scope.documentIds!.includes(id))
25-
: scope.documentIds
26+
? requested.documentIds.filter((id) => includesLiveDocument(scopeDocumentIds, id))
27+
: scopeDocumentIds
2628
: requested.documentIds
2729
if (documentIds?.length === 0) {
2830
throw new OrchestrationError('validation', 'The requested document is outside this search')

‎apps/sim/lib/sim-search/live/application.test.ts‎

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,9 @@ vi.mock('@/executor/utils/resolved-secret-content-projection', () => ({
7676
projectResolvedSecretModelContent: (value: string) => ({ safe: true, value }),
7777
}))
7878

79+
import { resourceScopeFromOwner, resourceScopeKey } from '@/lib/core/resource-scope'
7980
import { withSearchDiagnostics } from '@/lib/knowledge/search/diagnostics'
8081
import {
81-
decodeLiveReference,
82-
encodeLiveReference,
8382
matchesLiveFilters,
8483
readLiveDocument,
8584
searchLiveKnowledge,
@@ -89,6 +88,7 @@ import { NativeSearchError } from '@/lib/sim-search/live/http'
8988
import { createPolicyVerifier } from '@/lib/sim-search/live/policy'
9089
import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema'
9190
import { livePolicyFor } from '@/lib/sim-search/live/policy-store'
91+
import { decodeLiveReference, encodeLiveReference } from '@/lib/sim-search/live/reference'
9292
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
9393

9494
workspaceAuthzMockFns.mockPermissionSatisfies.mockImplementation(
@@ -1185,22 +1185,110 @@ describe('authorized live retrieval', () => {
11851185
})
11861186
).rejects.toBe('user_stop:test')
11871187
})
1188-
it('rejects cross-user document references before token resolution', async () => {
1189-
const search = await searchLiveKnowledge.execute({ principal, input })
1190-
const reference = decodeLiveReference(search.results[0].documentId)
1191-
mocks.resolveAccount.mockClear()
1192-
await expect(
1188+
describe('live document IDs', () => {
1189+
const workspaceScope = resourceScopeKey(resourceScopeFromOwner({ workspaceId: 'workspace' }))
1190+
/** The earlier wire form, spelled out so these tests pin what older conversations hold. */
1191+
const legacyId = (fields: Record<string, string>) =>
1192+
`live:${Buffer.from(JSON.stringify({ v: 1, ...fields })).toString('base64url')}`
1193+
const fields = { account: 'account', provider: 'google_drive' as const, id: 'doc' }
1194+
const read = (documentId: string, extra: Record<string, unknown> = {}, as = principal) =>
11931195
readLiveDocument.execute({
1194-
principal,
1196+
principal: as,
11951197
input: {
11961198
workspaceId: 'workspace',
1197-
documentId: encodeLiveReference({ ...reference, user: 'someone-else' }),
1199+
documentId,
11981200
limit: 3,
11991201
resultSecretRegistry: new ResolvedSecretTraceRegistry([]),
1202+
...extra,
12001203
},
12011204
})
1202-
).rejects.toThrow('Document not found')
1203-
expect(mocks.resolveAccount).not.toHaveBeenCalled()
1205+
1206+
it('mints a compact ID naming no reader or scope that reads back', async () => {
1207+
const search = await searchLiveKnowledge.execute({ principal, input })
1208+
const documentId = search.results[0]!.documentId
1209+
expect(documentId).toMatch(/^live2:google_drive~account~doc~[0-9a-f]{6}$/)
1210+
const result = await read(documentId)
1211+
expect(result.documentId).toBe(documentId)
1212+
expect(result.chunks[0]?.content).toBe('Evidence')
1213+
expect(mocks.resolveAccount).toHaveBeenLastCalledWith(
1214+
expect.objectContaining({ workspaceId: 'workspace' }),
1215+
'reader',
1216+
'account',
1217+
expect.anything()
1218+
)
1219+
})
1220+
it("refuses another member's personal account through the reader's own account list", async () => {
1221+
mocks.resolveAccount.mockImplementation(async (_owner, _userId, accountId: string) => {
1222+
if (accountId !== 'account')
1223+
throw new NativeSearchError('reconnect', 'This search connection is no longer available.')
1224+
return { account, accessToken: 'secret' }
1225+
})
1226+
const theirs = encodeLiveReference({ ...fields, account: 'their-account' })
1227+
await expect(read(theirs)).rejects.toMatchObject({ code: 'unauthorized' })
1228+
expect(mocks.resolveAccount).toHaveBeenCalledWith(
1229+
expect.anything(),
1230+
'reader',
1231+
'their-account',
1232+
expect.anything()
1233+
)
1234+
expect(mocks.read).not.toHaveBeenCalled()
1235+
})
1236+
it("gates a shared account's ID by the reader's own verification", async () => {
1237+
const search = await searchLiveKnowledge.execute({ principal, input })
1238+
mocks.service.mockResolvedValue({
1239+
policy: defaultLiveSearchPolicy(),
1240+
partial: false,
1241+
verify: async () => false,
1242+
})
1243+
const other = createSessionPrincipal({ userId: 'other-reader', sessionId: 's2' })
1244+
await expect(read(search.results[0]!.documentId, {}, other)).rejects.toThrow(
1245+
'outside your organization'
1246+
)
1247+
expect(mocks.read).not.toHaveBeenCalled()
1248+
})
1249+
it.each([
1250+
['a missing field', 'live2:google_drive~account'],
1251+
['an unknown provider', 'live2:nowhere~account~doc'],
1252+
['a broken escape', 'live2:google_drive~account~%E0%A4'],
1253+
['too many fields', 'live2:google_drive~a~b~c~d~e~f~g~h'],
1254+
['an unreadable earlier form', 'live:not-a-reference'],
1255+
['no prefix', 'google_drive~account~doc'],
1256+
])('asks the caller to search again for %s', async (_case, documentId) => {
1257+
await expect(read(documentId)).rejects.toThrow('Search again')
1258+
expect(mocks.resolveAccount).not.toHaveBeenCalled()
1259+
})
1260+
it('still reads an earlier-form ID minted for this reader and scope', async () => {
1261+
const result = await read(legacyId({ user: 'reader', scope: workspaceScope, ...fields }))
1262+
expect(result.chunks[0]?.content).toBe('Evidence')
1263+
})
1264+
it.each([
1265+
['reader', { user: 'someone-else', scope: workspaceScope }],
1266+
['scope', { user: 'reader', scope: 'workspace:elsewhere' }],
1267+
])(
1268+
'rejects an earlier-form ID minted for another %s before token resolution',
1269+
async (_case, binding) => {
1270+
await expect(read(legacyId({ ...binding, ...fields }))).rejects.toThrow(
1271+
'Document not found'
1272+
)
1273+
expect(mocks.resolveAccount).not.toHaveBeenCalled()
1274+
}
1275+
)
1276+
it('matches a selected document across the earlier and current forms', async () => {
1277+
const current = encodeLiveReference(fields)
1278+
const earlier = legacyId({ user: 'reader', scope: workspaceScope, ...fields })
1279+
await expect(read(current, { filters: { documentIds: [earlier] } })).resolves.toMatchObject({
1280+
documentId: current,
1281+
})
1282+
await expect(read(earlier, { filters: { documentIds: [current] } })).resolves.toBeDefined()
1283+
expect(
1284+
matchesLiveFilters(document, current, 'google_drive', { documentIds: [earlier] })
1285+
).toBe(true)
1286+
await expect(
1287+
read(current, {
1288+
filters: { documentIds: [encodeLiveReference({ ...fields, id: 'other' })] },
1289+
})
1290+
).rejects.toThrow('outside the selected search filters')
1291+
})
12041292
})
12051293
it('rejects a document moved out of scope while its content was being read', async () => {
12061294
const search = await searchLiveKnowledge.execute({ principal, input })

0 commit comments

Comments
 (0)