Skip to content

Commit 0b107f5

Browse files
committed
fix(knowledge): check the slot values actually being written, however they arrived
Four ways past the same guard, all one mistake: it was attached per call site on a belief about how the values were produced, rather than to the values about to be written. A tags payload that is truthy but not a JSON array, or not JSON at all, left the caller's raw slot values in place while the guard was skipped, because the flag keyed on the payload being present rather than on the name-keyed path having run. It is unconditional now, inside the transaction, against the values being inserted. That also closes a read taken before the lock on the successful path, which a deletion could invalidate before the insert — the same check-then-act the previous commit closed for the other branch. The predicate deciding whether an update writes a slot trimmed before testing, while every writer clears only on the exact empty value. So a whitespace-only value skipped both the lock and the check and was then stored: verbatim for a text tag, as zero for a number, as false for a boolean. It now tests exactly what the writers test, so the two cannot disagree again. A value with meaningful surrounding space was a write before and still is. The bulk upload path accepted the same slots and had no guard at all. Its transaction already takes the row lock first, so the check is one read for the batch rather than one per document. The shape underneath is still worth changing: three write sites each hand-wire both the lock and the check, and nothing makes a fourth do either. A seam owning "write the tag columns" would remove the chance to forget, and is left as follow-up rather than carried here.
1 parent 921b43c commit 0b107f5

4 files changed

Lines changed: 157 additions & 19 deletions

File tree

apps/sim/lib/knowledge/documents/service.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2184,6 +2184,14 @@ export async function createDocumentRecords(
21842184
const documentRecords = []
21852185
const documentProvenances: (DurableSecretProvenance | undefined)[] = []
21862186
const returnData: DocumentData[] = []
2187+
/**
2188+
* One representative value per slot the batch writes into, so the check
2189+
* below costs one definition read for the whole batch rather than one per
2190+
* document. Which document contributed a slot does not matter: the check
2191+
* asks only whether a definition covers the slot, and any document landing a
2192+
* value in an uncovered slot fails the batch.
2193+
*/
2194+
const batchTagSlotValues: Record<string, unknown> = {}
21872195

21882196
for (const [documentIndex, docData] of resolvedDocuments.entries()) {
21892197
const documentId = generateId()
@@ -2240,6 +2248,11 @@ export async function createDocumentRecords(
22402248
boolean2: processedTags.boolean2 ?? null,
22412249
boolean3: processedTags.boolean3 ?? null,
22422250
}
2251+
for (const [key, value] of Object.entries(baseDocument)) {
2252+
if (value !== null && value !== undefined && batchTagSlotValues[key] === undefined) {
2253+
batchTagSlotValues[key] = value
2254+
}
2255+
}
22432256
const source = createKnowledgeDocumentSourceValue(baseDocument)
22442257
const binding = storageKey
22452258
? (bindingByKey.get(storageKey) ?? sourceBindingByKey.get(storageKey))
@@ -2276,6 +2289,14 @@ export async function createDocumentRecords(
22762289
}
22772290

22782291
if (documentRecords.length > 0) {
2292+
/**
2293+
* The same check the single-document insert makes, against the same
2294+
* locked snapshot: this batch reaches the identical columns from the
2295+
* identical caller-supplied slot values, so leaving it out here left the
2296+
* whole bulk upload path as a way around the guard.
2297+
*/
2298+
await assertTagSlotsAreDefined(knowledgeBaseId, batchTagSlotValues, tx)
2299+
22792300
await tx.insert(document).values(documentRecords)
22802301
for (const [documentIndex, record] of documentRecords.entries()) {
22812302
const provenance = documentProvenances[documentIndex]
@@ -2676,14 +2697,6 @@ export async function createSingleDocument(
26762697
}
26772698
}
26782699
}
2679-
/**
2680-
* Only the slot-keyed branch needs checking: `resolveDocumentTags` above
2681-
* already refuses a name it cannot resolve to a definition, so the name-keyed
2682-
* branch cannot reach an undefined slot. The check itself runs inside the
2683-
* transaction below, under the knowledge base's row lock, because tag
2684-
* deletion clears the slot and drops its definition under that same lock.
2685-
*/
2686-
const validateTagSlots = !documentData.documentTagsData
26872700

26882701
const newDocument = {
26892702
id: documentId,
@@ -2708,9 +2721,18 @@ export async function createSingleDocument(
27082721

27092722
await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`)
27102723

2711-
if (validateTagSlots) {
2712-
await assertTagSlotsAreDefined(knowledgeBaseId, processedTags, tx)
2713-
}
2724+
/**
2725+
* Checked against exactly the slot values about to be inserted, whatever
2726+
* produced them. Conditioning this on how the tags were supplied is what let
2727+
* two bypasses through: a `documentTagsData` payload that is truthy but not
2728+
* a JSON array leaves the caller's direct slot values in place while
2729+
* skipping the name-keyed resolution, and even a payload that does resolve
2730+
* was matched against definitions read before this transaction opened, so a
2731+
* tag deletion committing in between stranded the value the resolution had
2732+
* just approved. Both disappear once the check reads the same locked
2733+
* snapshot the insert writes into.
2734+
*/
2735+
await assertTagSlotsAreDefined(knowledgeBaseId, processedTags, tx)
27142736

27152737
const kb = await tx
27162738
.select({

apps/sim/lib/knowledge/documents/tag-slot-lock.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ vi.mock('@/lib/knowledge/documents/processing-outbox-event', () => ({
6060
enqueueKnowledgeDocumentProcessing: mockEnqueueKnowledgeDocumentProcessing,
6161
}))
6262

63-
import { createSingleDocument, updateDocument } from '@/lib/knowledge/documents/service'
63+
import {
64+
createDocumentRecords,
65+
createSingleDocument,
66+
updateDocument,
67+
} from '@/lib/knowledge/documents/service'
6468

6569
const KNOWLEDGE_BASE_ID = 'kb-1'
6670
const NOW = new Date('2026-01-01T00:00:00.000Z')
@@ -146,6 +150,17 @@ describe('updateDocument tag-slot validation', () => {
146150
expect(dbChainMockFns.execute).not.toHaveBeenCalled()
147151
expect(orderForTable(dbChainMockFns.update, document)).toBeGreaterThan(0)
148152
})
153+
154+
it('treats a whitespace-only value as a write, because the writer stores it rather than clearing', async () => {
155+
queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')])
156+
157+
await expect(
158+
updateDocument('doc-1', { tag2: ' ' }, 'req-1', { knowledgeBaseId: KNOWLEDGE_BASE_ID })
159+
).rejects.toMatchObject({ code: 'validation' })
160+
161+
expect(dbChainMockFns.execute).toHaveBeenCalled()
162+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
163+
})
149164
})
150165

151166
describe('createSingleDocument tag-slot validation', () => {
@@ -202,4 +217,77 @@ describe('createSingleDocument tag-slot validation', () => {
202217

203218
expect(orderForTable(dbChainMockFns.insert, document)).toBe(-1)
204219
})
220+
221+
it('refuses a slot carried alongside a tags payload that is not a JSON array', async () => {
222+
queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')])
223+
224+
await expect(
225+
createSingleDocument(
226+
{ ...documentData, tag2: 'purple', documentTagsData: '{"tagName":"colour"}' },
227+
KNOWLEDGE_BASE_ID,
228+
'req-1'
229+
)
230+
).rejects.toMatchObject({ code: 'validation' })
231+
232+
expect(orderForTable(dbChainMockFns.insert, document)).toBe(-1)
233+
})
234+
235+
it('refuses a slot carried alongside a tags payload that is not valid JSON', async () => {
236+
queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')])
237+
238+
await expect(
239+
createSingleDocument(
240+
{ ...documentData, tag2: 'purple', documentTagsData: 'not json at all' },
241+
KNOWLEDGE_BASE_ID,
242+
'req-1'
243+
)
244+
).rejects.toMatchObject({ code: 'validation' })
245+
246+
expect(orderForTable(dbChainMockFns.insert, document)).toBe(-1)
247+
})
248+
249+
it('refuses a whitespace-only slot value, which the insert would store rather than clear', async () => {
250+
queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')])
251+
252+
await expect(
253+
createSingleDocument({ ...documentData, tag2: ' ' }, KNOWLEDGE_BASE_ID, 'req-1')
254+
).rejects.toMatchObject({ code: 'validation' })
255+
256+
expect(orderForTable(dbChainMockFns.insert, document)).toBe(-1)
257+
})
258+
259+
describe('createDocumentRecords', () => {
260+
it('checks the batch inside the insert transaction, under the knowledge-base row lock', async () => {
261+
queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')])
262+
263+
await createDocumentRecords(
264+
[{ ...documentData, tag1: 'priority' }],
265+
KNOWLEDGE_BASE_ID,
266+
'req-1'
267+
)
268+
269+
const transactionOrder = dbChainMockFns.transaction.mock.invocationCallOrder[0]
270+
const lockOrder = dbChainMockFns.execute.mock.invocationCallOrder[0] ?? -1
271+
const definitionReadOrder = orderForTable(dbChainMockFns.from, knowledgeBaseTagDefinitions)
272+
const documentInsertOrder = orderForTable(dbChainMockFns.insert, document)
273+
274+
expect(lockOrder).toBeGreaterThan(transactionOrder)
275+
expect(definitionReadOrder).toBeGreaterThan(lockOrder)
276+
expect(documentInsertOrder).toBeGreaterThan(definitionReadOrder)
277+
})
278+
279+
it('refuses a batch whose slot no definition covers, and inserts nothing', async () => {
280+
queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')])
281+
282+
await expect(
283+
createDocumentRecords(
284+
[{ ...documentData }, { ...documentData, tag2: 'purple' }],
285+
KNOWLEDGE_BASE_ID,
286+
'req-1'
287+
)
288+
).rejects.toMatchObject({ code: 'validation' })
289+
290+
expect(orderForTable(dbChainMockFns.insert, document)).toBe(-1)
291+
})
292+
})
205293
})

apps/sim/lib/knowledge/tags/service.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,17 @@ export async function getNextAvailableSlot(
205205
* Get all tag definitions for a knowledge base
206206
*/
207207
/**
208-
* The slots a write would put a nonempty value into.
208+
* The slots a write would leave a value in.
209209
*
210-
* Clearing is not a write for this purpose: an empty value removes whatever a
211-
* slot holds, so it can never leave a value stranded behind a missing
212-
* definition.
210+
* Only the exact empty value counts as clearing, because that is exactly what
211+
* the writers treat as clearing: they map `''`, `null` and `undefined` to a
212+
* null column and persist everything else as given. A value that merely looks
213+
* empty is still stored — a whitespace-only text value is preserved verbatim, a
214+
* whitespace-only number parses to `0`, and a whitespace-only boolean falls
215+
* back to `false` — so classifying it as a clear would skip both the knowledge
216+
* base's row lock and the definition check while still landing a value in a
217+
* slot no definition covers. Matching the writers' own condition rather than a
218+
* looser one is what keeps the two from drifting apart again.
213219
*/
214220
function collectWrittenTagSlots(slotValues: Record<string, unknown>): string[] {
215221
return Object.entries(slotValues)
@@ -218,7 +224,7 @@ function collectWrittenTagSlots(slotValues: Record<string, unknown>): string[] {
218224
(VALID_TAG_SLOTS as readonly string[]).includes(slot) &&
219225
value !== undefined &&
220226
value !== null &&
221-
String(value).trim().length > 0
227+
String(value) !== ''
222228
)
223229
.map(([slot]) => slot)
224230
}
@@ -227,7 +233,7 @@ function collectWrittenTagSlots(slotValues: Record<string, unknown>): string[] {
227233
* Whether a write touches a tag slot at all.
228234
*
229235
* A writer uses this to decide whether it needs the knowledge base's row lock:
230-
* only a write that lands a nonempty slot value can race tag deletion, so a
236+
* only a write that lands a slot value can race tag deletion, so a
231237
* write that touches no slot must not take — or wait on — that lock.
232238
*/
233239
export function writesTagSlots(slotValues: Record<string, unknown>): boolean {

apps/sim/lib/knowledge/tags/undefined-slot-write.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { knowledgeBaseTagDefinitions } from '@sim/db/schema'
66
import { queueTableRows, resetDbChainMock } from '@sim/testing'
77
import { beforeEach, describe, expect, it, vi } from 'vitest'
88
import { OrchestrationError } from '@/lib/core/orchestration/types'
9-
import { assertTagSlotsAreDefined } from '@/lib/knowledge/tags/service'
9+
import { assertTagSlotsAreDefined, writesTagSlots } from '@/lib/knowledge/tags/service'
1010

1111
const KNOWLEDGE_BASE_ID = 'kb-1'
1212
const NOW = new Date('2026-01-01T00:00:00.000Z')
@@ -67,4 +67,26 @@ describe('assertTagSlotsAreDefined', () => {
6767
assertTagSlotsAreDefined(KNOWLEDGE_BASE_ID, { filename: 'renamed.txt', enabled: true })
6868
).resolves.toBeUndefined()
6969
})
70+
71+
it('refuses a whitespace-only value, which every writer stores rather than clears', async () => {
72+
queueTableRows(knowledgeBaseTagDefinitions, [definition('tag1', 'category')])
73+
74+
await expect(
75+
assertTagSlotsAreDefined(KNOWLEDGE_BASE_ID, { tag2: ' ', number1: ' ', boolean1: '\t' })
76+
).rejects.toMatchObject({ code: 'validation' })
77+
})
78+
})
79+
80+
describe('writesTagSlots', () => {
81+
it('counts only the exact empty value as clearing', () => {
82+
expect(writesTagSlots({ tag1: '' })).toBe(false)
83+
expect(writesTagSlots({ tag1: null, tag2: undefined })).toBe(false)
84+
expect(writesTagSlots({ filename: 'renamed.txt' })).toBe(false)
85+
})
86+
87+
it('counts a value that only looks empty as a write, since the writers preserve it', () => {
88+
expect(writesTagSlots({ tag1: ' ' })).toBe(true)
89+
expect(writesTagSlots({ number1: ' ' })).toBe(true)
90+
expect(writesTagSlots({ boolean1: '\n' })).toBe(true)
91+
})
7092
})

0 commit comments

Comments
 (0)