Skip to content

Commit c77ebc3

Browse files
committed
fix(mothership): address review round on content delivery
Committed writes answer saved-not-shown instead of a retryable error, keep their panel refresh, and account per operation. Chat streams pin inline bytes off, taint needs a registry, compile admits before its cache, owned files bind to their context, and count/truncation oracles are closed for Chat in knowledge, files search and row search. Table selection cells are projected before escaping and coerced import cells keep their taint.
1 parent ef17ffc commit c77ebc3

43 files changed

Lines changed: 1150 additions & 101 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/rules/sim-content-delivery.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
---
22
description: How stored content (workspace files, run files, knowledge, table rows) reaches Chat's model, and the per-path policy. Read before changing a use case, route or Chat surface that returns such content.
33
paths:
4-
- "apps/sim/lib/core/application/content-delivery.ts"
4+
- "apps/sim/lib/core/application/**"
55
- "apps/sim/lib/mothership/content-delivery/**"
6-
- "apps/sim/lib/**/application/admit-*.ts"
6+
- "apps/sim/lib/workspace-files/application/**"
7+
- "apps/sim/lib/knowledge/application/**"
8+
- "apps/sim/lib/table/application/**"
9+
- "apps/sim/lib/workflows/application/**"
10+
- "apps/sim/lib/mothership/chat/application/**"
711
- "apps/sim/lib/**/application/operations.ts"
812
- "apps/sim/lib/mothership/agent-cli/**"
9-
- "apps/sim/lib/mothership/tools/sandbox-resource-transport.ts"
13+
- "apps/sim/lib/mothership/tools/**"
14+
- "apps/sim/app/api/v2/**"
1015
---
1116

1217
# Sim Content Delivery to Chat

‎.cursor/rules/sim-content-delivery.mdc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
description: "How stored content (workspace files, run files, knowledge, table rows) reaches Chat's model, and the per-path policy. Read before changing a use case, route or Chat surface that returns such content."
3-
globs: ["apps/sim/lib/core/application/content-delivery.ts","apps/sim/lib/mothership/content-delivery/**","apps/sim/lib/**/application/admit-*.ts","apps/sim/lib/**/application/operations.ts","apps/sim/lib/mothership/agent-cli/**","apps/sim/lib/mothership/tools/sandbox-resource-transport.ts"]
3+
globs: ["apps/sim/lib/core/application/**","apps/sim/lib/mothership/content-delivery/**","apps/sim/lib/workspace-files/application/**","apps/sim/lib/knowledge/application/**","apps/sim/lib/table/application/**","apps/sim/lib/workflows/application/**","apps/sim/lib/mothership/chat/application/**","apps/sim/lib/**/application/operations.ts","apps/sim/lib/mothership/agent-cli/**","apps/sim/lib/mothership/tools/**","apps/sim/app/api/v2/**"]
44
---
55

66
<!-- Generated from .claude/rules/sim-content-delivery.md by `bun run skills:sync`. Edit the source, not this file. -->

‎apps/sim/lib/core/application/content-delivery.test.ts‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ describe('runDeclaredContentOperation', () => {
208208
expect(refusal).toMatchObject({ message: expect.stringContaining('The change was saved') })
209209
})
210210

211-
it('answers with a gate failure during a write only after its effects completed', async () => {
211+
it('answers a write whose gate failed as saved-not-shown, after its effects completed', async () => {
212212
const failure = new Error('history unavailable')
213213
const failing: ContentDeliveryGate = {
214214
async admit() {
@@ -231,7 +231,37 @@ describe('runDeclaredContentOperation', () => {
231231
).catch((error: unknown) => error)
232232
)
233233
expect(events).toEqual(['withheld=1', 'audit recorded'])
234-
expect(outcome).toBe(failure)
234+
/** Not the gate's error: that would invite a retry of a write that already committed. */
235+
expect(outcome).toBeInstanceOf(ContentRefusedError)
236+
expect(outcome).toMatchObject({ message: expect.stringContaining('The change was saved') })
237+
/** The request's transport still sees the failure, to answer it itself (the proxy's 502). */
238+
const record = await observeContentDelivery(failing, () =>
239+
trackContentDelivery(() =>
240+
runDeclaredContentOperation(SOURCED, CHAT, async () => {
241+
await admitWrittenContent(CHAT, [source('written')])
242+
return 'saved row'
243+
}).catch(() => 'answered')
244+
)
245+
)
246+
expect(record.gateFailure).toBe(failure)
247+
})
248+
249+
it('does not let a nested use case account for what its enclosing operation returns', async () => {
250+
const admitAll: ContentDeliveryGate = {
251+
async admit(sources) {
252+
return sources.map(() => true)
253+
},
254+
}
255+
const outcome = await observeContentDelivery(admitAll, () =>
256+
runDeclaredContentOperation(SOURCED, CHAT, async () => {
257+
await runDeclaredContentOperation(SOURCED, CHAT, async () => {
258+
await admitContent(CHAT, [source('nested')])
259+
return 'nested'
260+
})
261+
return 'outer content never classified'
262+
}).catch((error: unknown) => error)
263+
)
264+
expect(outcome).toBeInstanceOf(ContentDeliveryUnaccountedError)
235265
})
236266

237267
it('rethrows a gate failure at once when no operation is open to answer with it', async () => {
@@ -265,7 +295,12 @@ describe('trackContentDelivery', () => {
265295
noteContentDeliveryOperation(SOURCED, CHAT)
266296
return 'response'
267297
})
268-
expect(record).toEqual({ result: 'response', unaccounted: true, admitted: [] })
298+
expect(record).toEqual({
299+
result: 'response',
300+
unaccounted: true,
301+
committedWrite: false,
302+
admitted: [],
303+
})
269304
})
270305

271306
it('does not report a none operation, or a sourced one another caller ran', async () => {

‎apps/sim/lib/core/application/content-delivery.ts‎

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,6 @@ interface ContentLedger {
6464
admitted: ContentProvenance[]
6565
/** A committed write's returned item was refused; the operation answers with a refusal. */
6666
withheldAfterWrite?: ContentSourceKind
67-
/** A committed write's gate failed; the operation completes, then answers with this error. */
68-
failedAfterWrite?: unknown
6967
/** The gate itself failed while this ledger was open, whatever the route then answered. */
7068
gateFailure?: unknown
7169
}
@@ -185,8 +183,24 @@ export class ContentAdmission {
185183
}
186184
}
187185

186+
/**
187+
* Accounts for the operation running now and the requests around it. An enclosing operation is
188+
* not marked: a nested use case's admission covers what it returned, not what the outer one adds.
189+
*/
188190
function markAccounted(): void {
189-
for (let ledger = ledgers.getStore(); ledger; ledger = ledger.parent) ledger.accounted = true
191+
const current = ledgers.getStore()
192+
if (!current) return
193+
current.accounted = true
194+
for (let ledger = current.parent; ledger; ledger = ledger.parent) {
195+
if (ledger.declaration === undefined) ledger.accounted = true
196+
}
197+
}
198+
199+
/** A committed write returned an item of `kind` its gate refused or could not decide. */
200+
function withholdAfterWrite(kind: ContentSourceKind): void {
201+
for (let ledger = ledgers.getStore(); ledger; ledger = ledger.parent) {
202+
ledger.withheldAfterWrite ??= kind
203+
}
190204
}
191205

192206
/**
@@ -254,25 +268,20 @@ export async function admitWrittenContent(
254268
admission = await admitContent(principal, sources)
255269
} catch (error) {
256270
/**
257-
* A gate that cannot decide (its record could not be written) must not undo the write's
258-
* audit and effects either: every item is withheld, and the operation answers with the error.
271+
* A gate that cannot decide (its record could not be written) must not undo the write's audit
272+
* and effects either: every item is withheld, and the operation answers "saved, not shown".
273+
* A transport that must answer the failure itself reads it from the request's ledger.
259274
*/
260-
const ledger = ledgers.getStore()
261-
if (!ledger) throw error
262-
for (let open: ContentLedger | undefined = ledger; open; open = open.parent) {
263-
open.failedAfterWrite ??= error
264-
}
275+
const [first] = sources
276+
if (!ledgers.getStore() || !first) throw error
277+
withholdAfterWrite(first.kind)
265278
return new ContentAdmission(
266279
sources,
267280
sources.map(() => false)
268281
)
269282
}
270283
const refused = admission.refused[0]
271-
if (refused) {
272-
for (let ledger = ledgers.getStore(); ledger; ledger = ledger.parent) {
273-
ledger.withheldAfterWrite ??= refused.kind
274-
}
275-
}
284+
if (refused) withholdAfterWrite(refused.kind)
276285
return admission
277286
}
278287

@@ -285,14 +294,28 @@ export async function admitEach<T>(
285294
principal: Principal,
286295
items: readonly T[],
287296
classify: (items: readonly T[]) => Promise<readonly ContentSource[]>,
288-
options: { written?: boolean } = {}
297+
options: { written?: ContentSourceKind } = {}
289298
): Promise<T[]> {
290299
if (items.length === 0 || !mustClassifyContent(principal)) {
291300
await admitContent(principal, [])
292301
return [...items]
293302
}
294-
const sources = await classify(items)
295-
const admission = await (options.written ? admitWrittenContent : admitContent)(principal, sources)
303+
if (!options.written) {
304+
const sources = await classify(items)
305+
const admission = await admitContent(principal, sources)
306+
return items.filter((_, index) => admission.admits(sources[index]))
307+
}
308+
/** A committed write whose items cannot even be classified is saved, then withheld whole. */
309+
let sources: readonly ContentSource[]
310+
try {
311+
sources = await classify(items)
312+
} catch (error) {
313+
if (!ledgers.getStore()) throw error
314+
markAccounted()
315+
withholdAfterWrite(options.written)
316+
return []
317+
}
318+
const admission = await admitWrittenContent(principal, sources)
296319
return items.filter((_, index) => admission.admits(sources[index]))
297320
}
298321

@@ -308,13 +331,26 @@ export async function requireAdmittedContent(
308331
if (!admission.admits(source)) throw new ContentRefusedError(source.kind)
309332
}
310333

334+
/** The gate failed during a dispatched request, which may have committed a write first. */
335+
export class ContentGateFailure extends Error {
336+
constructor(
337+
readonly gateFailure: unknown,
338+
readonly committedWrite: boolean
339+
) {
340+
super('The content gate failed during the request', { cause: gateFailure })
341+
this.name = 'ContentGateFailure'
342+
}
343+
}
344+
311345
/** What one dispatched request delivered, as the transport that dispatched it needs to know. */
312346
export interface ContentDeliveryRecord<T> {
313347
result: T
314348
/** Chat ran a `sourced` operation whose use case never accounted for its content. */
315349
unaccounted: boolean
316350
/** The gate failed to decide during the request; see {@link ContentLedger.gateFailure}. */
317351
gateFailure?: unknown
352+
/** A write committed, then its returned content was withheld: the change happened. */
353+
committedWrite: boolean
318354
/** Provenance of every admitted item, in admission order. */
319355
admitted: readonly ContentProvenance[]
320356
}
@@ -333,10 +369,18 @@ export async function trackContentDelivery<T>(
333369
accounted: false,
334370
admitted: [],
335371
}
336-
const result = await ledgers.run(ledger, execute)
372+
let result: T
373+
try {
374+
result = await ledgers.run(ledger, execute)
375+
} catch (error) {
376+
/** The request threw because its gate failed; its transport answers that, not the error. */
377+
if (ledger.gateFailure === undefined) throw error
378+
throw new ContentGateFailure(ledger.gateFailure, ledger.withheldAfterWrite !== undefined)
379+
}
337380
return {
338381
result,
339382
unaccounted: ledger.sourcedOperationAdmitted && !ledger.accounted,
383+
committedWrite: ledger.withheldAfterWrite !== undefined,
340384
admitted: ledger.admitted,
341385
...(ledger.gateFailure === undefined ? {} : { gateFailure: ledger.gateFailure }),
342386
}
@@ -381,7 +425,6 @@ export async function runDeclaredContentOperation<T>(
381425
}
382426
const result = await ledgers.run(ledger, execute)
383427
await complete?.(result)
384-
if (ledger.failedAfterWrite) throw ledger.failedAfterWrite
385428
if (operation.contentDelivery === 'sourced' && !ledger.accounted) {
386429
throw new ContentDeliveryUnaccountedError(operation.id)
387430
}

‎apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,12 @@ async function assertBlockedConsumers(
208208
} else {
209209
expect(imported).toMatchObject({ failed: [] })
210210
expect(imported.added).toHaveLength(1)
211+
/** The accepted document queued its processing; that work is removed with the fixture. */
212+
const events = await db
213+
.select({ id: outboxEvent.id })
214+
.from(outboxEvent)
215+
.where(sql`${outboxEvent.payload}::jsonb ->> 'documentId' = ${imported.added[0].documentId}`)
216+
trackedEventIds.push(...events.map((event) => event.id))
211217
}
212218
}
213219

‎apps/sim/lib/knowledge/application/admit-knowledge-content.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Principal } from '@sim/auth/principal'
2-
import { admitEach } from '@/lib/core/application/content-delivery'
2+
import { admitEach, type ContentSourceKind } from '@/lib/core/application/content-delivery'
33
import {
44
classifyKnowledgeChunks,
55
classifyKnowledgeDocumentMetadata,
@@ -10,6 +10,9 @@ interface KnowledgeAdmissionOptions {
1010
written?: boolean
1111
}
1212

13+
const writtenAs = (options: KnowledgeAdmissionOptions, kind: ContentSourceKind) =>
14+
options.written ? { written: kind } : {}
15+
1316
/**
1417
* Admits each document whose rendered metadata a result returns, on its own, and returns the ids
1518
* admitted. `value` is what the result shows of the document, so only secrets it contains are
@@ -32,7 +35,7 @@ export async function admitKnowledgeDocuments(
3235
view: { derived: item.value },
3336
}))
3437
},
35-
options
38+
writtenAs(options, 'knowledge-document')
3639
)
3740
return new Set(admitted.map((item) => item.id))
3841
}
@@ -59,7 +62,7 @@ export async function admitKnowledgeChunks(
5962
view: chunk.value === undefined ? ('complete' as const) : { derived: chunk.value },
6063
}))
6164
},
62-
options
65+
writtenAs(options, 'knowledge-chunk')
6366
)
6467
return new Set(admitted.map((chunk) => chunk.id))
6568
}

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

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
1+
import type { Principal } from '@sim/auth/principal'
2+
import {
3+
createDelegatedPrincipal,
4+
createSessionPrincipal,
5+
} from '@sim/testing/factories/principal.factory'
26
import {
37
knowledgeContextsMock,
48
knowledgeContextsMockFns,
@@ -8,6 +12,7 @@ import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/wo
812
import { beforeEach, describe, expect, it, vi } from 'vitest'
913

1014
const mocks = vi.hoisted(() => ({
15+
admitKnowledgeChunks: vi.fn(),
1116
queryChunks: vi.fn(),
1217
batchChunkOperation: vi.fn(),
1318
createChunk: vi.fn(),
@@ -36,6 +41,11 @@ vi.mock('@/lib/knowledge/model-input-provenance', () => ({
3641

3742
vi.mock('@/providers/utils', () => providersUtilsMock)
3843

44+
vi.mock('@/lib/knowledge/application/admit-knowledge-content', () => ({
45+
admitKnowledgeChunks: mocks.admitKnowledgeChunks,
46+
}))
47+
48+
import { admitContent } from '@/lib/core/application/content-delivery'
3949
import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
4050
import { WORKSPACE_ACCESS_SCOPE } from '@/lib/knowledge/access/scope'
4151
import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors'
@@ -57,6 +67,65 @@ describe('knowledge chunk application use cases', () => {
5767
})
5868
})
5969

70+
/** Stands in for the gate's verdicts; accounting is the real primitive's, as the helper's is. */
71+
function admitOnly(ids: string[]) {
72+
mocks.admitKnowledgeChunks.mockImplementation(async (principal: Principal) => {
73+
await admitContent(principal, [])
74+
return new Set(ids)
75+
})
76+
}
77+
78+
describe('a searched chunk listing', () => {
79+
const page = {
80+
chunks: [
81+
{ id: 'chunk-1', content: 'first' },
82+
{ id: 'chunk-2', content: 'second' },
83+
],
84+
pagination: { total: 7, limit: 2, offset: 4, hasMore: true },
85+
}
86+
87+
beforeEach(() => {
88+
knowledgeContextsMockFns.mockResolveCanonicalActiveKnowledgeDocumentContext.mockResolvedValue(
89+
{
90+
workspaceId: 'workspace-1',
91+
workspaceOrganizationId: null,
92+
allowPersonalApiKeys: true,
93+
billedAccountUserId: 'billing-owner-1',
94+
access: { get: async () => WORKSPACE_ACCESS_SCOPE },
95+
knowledgeBaseId: 'knowledge-1',
96+
knowledgeBase: { id: 'knowledge-1' },
97+
documentId: 'document-1',
98+
document: { id: 'document-1', processingStatus: 'completed', enabled: true },
99+
}
100+
)
101+
mocks.queryChunks.mockResolvedValue(page)
102+
admitOnly(['chunk-1'])
103+
})
104+
105+
it('reports Chat a total of the matches it was given, not of withheld ones', async () => {
106+
const result = await listKnowledgeChunks.execute({
107+
principal: createDelegatedPrincipal({
108+
workspaceId: 'workspace-1',
109+
audience: 'sim:knowledge',
110+
}),
111+
input: { knowledgeBaseId: 'knowledge-1', documentId: 'document-1', search: 'token' },
112+
})
113+
114+
expect(result.chunks.map((chunk) => chunk.id)).toEqual(['chunk-1'])
115+
expect(result.pagination).toEqual({ total: 5, limit: 2, offset: 4, hasMore: true })
116+
})
117+
118+
it('keeps the full total for a person', async () => {
119+
admitOnly(['chunk-1', 'chunk-2'])
120+
const result = await listKnowledgeChunks.execute({
121+
principal: createSessionPrincipal(),
122+
input: { knowledgeBaseId: 'knowledge-1', documentId: 'document-1', search: 'token' },
123+
})
124+
125+
expect(result.pagination).toEqual(page.pagination)
126+
})
127+
})
128+
60129
it('returns a typed transient failure before querying chunks for a processing document', async () => {
61130
const promise = listKnowledgeChunks.execute({
62131
principal: createSessionPrincipal(),

0 commit comments

Comments
 (0)