Skip to content

Commit fcea50d

Browse files
fix(provenance): stop size limits silently dropping secret provenance (#6867)
* fix(provenance): stop size limits silently dropping secret provenance A bundle-selection cap counted cells rather than rows, so a 25-column table insert lost secret provenance for every row past 400 — the whole batch was stamped unknown with nothing logged. The same number lived in the sender, the runtime type guard, and the route contract. Consolidate every provenance limit into one definition: an 8MB serialized envelope and 10,000 distinct secrets. The pair had been copied into seven modules under fourteen names, and several copies had drifted into bounding inputs — rows, cells, files, chunks — rather than the envelope. Remove every limit that could refuse a legal payload, add write-side cause logging and a workspace-visible audit entry when a read proceeds on unrecorded provenance, and repair the existing unknown rows. * fix(provenance): close a repair race and stop memory double-reporting The repair matched sidecars by the id its page captured, so a provenance-aware write committing between the snapshot and the delete had its fresh exact sidecar removed and its marker cleared behind it — a secret-bearing row left reading as legacy. The delete now re-checks status, which under READ COMMITTED re-evaluates against the writer's committed row so it no longer matches. Walk the candidate set by keyset over row_id. A page whose rows were all repaired concurrently clears nothing, and terminating on "cleared nothing" ended the walk with the rest of the backlog untouched. Memory reported unrecorded provenance twice, and counted records even when the surface was enforced — auditing a fail-open read that had actually failed closed. * fix(provenance): take the repair's locks in the writer's order The repair deleted the sidecar and only then updated its parent row, while mutateTableRowsWithSecretProvenance locks user_table_rows up front and upserts the sidecar inside the same transaction. Opposite orders, so an overlapping write deadlocked and Postgres resolved it by aborting either the deployment or somebody's table write. Lock the parent first, in id order, matching lockTableRows. Holding that lock is also what makes the status re-check decisive rather than racy: the writer commits its sidecar and its marker under the same lock, so once it is held the write is either wholly done or has not begun.
1 parent 483ff12 commit fcea50d

22 files changed

Lines changed: 1174 additions & 198 deletions

apps/sim/app/api/memory/secret-provenance.test.ts

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,19 @@
55
import { memorySecretProvenance } from '@sim/db/schema'
66
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
77
import { NextRequest } from 'next/server'
8-
import { beforeEach, describe, expect, it } from 'vitest'
8+
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
const { mockIsEnforced, mockReport } = vi.hoisted(() => ({
11+
mockIsEnforced: vi.fn(() => false),
12+
mockReport: vi.fn(),
13+
}))
14+
15+
vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({
16+
DURABLE_SECRET_PROVENANCE_SURFACES: ['memory', 'table-row', 'knowledge'],
17+
isDurableSecretProvenanceEnforced: mockIsEnforced,
18+
reportUnrecordedDurableProvenance: mockReport,
19+
}))
20+
921
import { AuthType } from '@/lib/auth/hybrid'
1022
import {
1123
PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
@@ -47,6 +59,8 @@ function privateMemoryWrite(
4759
describe('memory write secret provenance', () => {
4860
beforeEach(() => {
4961
resetDbChainMock()
62+
mockReport.mockClear()
63+
mockIsEnforced.mockReturnValue(false)
5064
})
5165
it('classifies a headerless external write as exact-empty', () => {
5266
const request = new NextRequest('http://localhost/api/memory', { method: 'POST' })
@@ -200,7 +214,12 @@ describe('memory write secret provenance', () => {
200214
if (!result.success) expect(result.response.status).toBe(400)
201215
})
202216

203-
it('bounds only requested private response provenance without querying sidecars', async () => {
217+
/**
218+
* A read of this width used to be refused outright on the record count alone. How many memories
219+
* crossed said nothing about whether their provenance could be established, so the read now
220+
* vouches for them and the page size is what keeps the statement count bounded.
221+
*/
222+
it('vouches for a very wide crossing instead of refusing on the record count', async () => {
204223
const request = new NextRequest('http://localhost/api/memory', {
205224
headers: {
206225
[PRIVATE_TOOL_METADATA_REQUEST_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1,
@@ -220,12 +239,18 @@ describe('memory write secret provenance', () => {
220239
})
221240

222241
await expect(response.json()).resolves.toMatchObject({
223-
[RESOLVED_SECRET_PROVENANCE_FIELD]: { version: 1, complete: false, entries: [] },
242+
[RESOLVED_SECRET_PROVENANCE_FIELD]: { version: 1, complete: true, entries: [] },
224243
})
225-
expect(dbChainMockFns.select).not.toHaveBeenCalled()
244+
/** Eleven pages of a thousand, not one statement per handful of memories. */
245+
expect(dbChainMockFns.select.mock.calls.length).toBeLessThanOrEqual(11)
226246
})
227247

228-
it('marks oversized aggregate sidecar provenance incomplete before importing it', async () => {
248+
/**
249+
* A sidecar too large to carry reads as unrecorded, not as a reason to fail the read. The run
250+
* keeps its other provenance and proceeds without this memory's — best effort, with the risk
251+
* recorded — rather than refusing every projection for the rest of the run.
252+
*/
253+
it('treats a sidecar too large to carry as unrecorded rather than failing the read', async () => {
229254
queueTableRows(memorySecretProvenance, [
230255
{
231256
memoryId: 'memory-1',
@@ -253,7 +278,66 @@ describe('memory write secret provenance', () => {
253278
})
254279

255280
await expect(response.json()).resolves.toMatchObject({
256-
[RESOLVED_SECRET_PROVENANCE_FIELD]: { version: 1, complete: false, entries: [] },
281+
[RESOLVED_SECRET_PROVENANCE_FIELD]: { version: 1, complete: true, entries: [] },
282+
})
283+
})
284+
/**
285+
* One entry for the read, not one per record: the per-record import knows no workspace, so its
286+
* report can only ever be a log line, and passing the workspace down instead would write
287+
* thousands of audit rows for a single event.
288+
*/
289+
it('reports one aggregated entry for a read that proceeded unvouched', async () => {
290+
const request = new NextRequest('http://localhost/api/memory', {
291+
headers: {
292+
[PRIVATE_TOOL_METADATA_REQUEST_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1,
293+
},
257294
})
295+
296+
await createMemoryResponse({
297+
request,
298+
authType: AuthType.INTERNAL_JWT,
299+
userId: 'user-1',
300+
workspaceId: 'workspace-1',
301+
body: { success: true },
302+
memories: [
303+
{ id: 'memory-1', data: 'value', secretProvenanceVersion: 1 },
304+
{ id: 'memory-2', data: 'value', secretProvenanceVersion: 1 },
305+
],
306+
})
307+
308+
expect(mockReport).toHaveBeenCalledTimes(1)
309+
expect(mockReport).toHaveBeenCalledWith(
310+
expect.objectContaining({
311+
surface: 'memory',
312+
cause: 'durable-provenance-unknown',
313+
affectedCount: 2,
314+
workspaceId: 'workspace-1',
315+
})
316+
)
317+
})
318+
319+
/**
320+
* Under enforcement the import fails the registry closed rather than proceeding, so there is no
321+
* fail-open read to record. Counting those records anyway would audit something that never
322+
* happened, in the one trail whose whole purpose is to say a read went ahead unvouched.
323+
*/
324+
it('records nothing when the surface is enforced and the read fails closed', async () => {
325+
mockIsEnforced.mockReturnValue(true)
326+
const request = new NextRequest('http://localhost/api/memory', {
327+
headers: {
328+
[PRIVATE_TOOL_METADATA_REQUEST_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1,
329+
},
330+
})
331+
332+
await createMemoryResponse({
333+
request,
334+
authType: AuthType.INTERNAL_JWT,
335+
userId: 'user-1',
336+
workspaceId: 'workspace-1',
337+
body: { success: true },
338+
memories: [{ id: 'memory-1', data: 'value', secretProvenanceVersion: 1 }],
339+
})
340+
341+
expect(mockReport).not.toHaveBeenCalled()
258342
})
259343
})

apps/sim/app/api/memory/secret-provenance.ts

Lines changed: 79 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Buffer } from 'buffer'
21
import { db } from '@sim/db'
32
import { memorySecretProvenance } from '@sim/db/schema'
43
import { inArray } from 'drizzle-orm'
@@ -10,6 +9,10 @@ import {
109
EXACT_EMPTY_DURABLE_SECRET_PROVENANCE,
1110
importDurableSecretProvenance,
1211
} from '@/lib/execution/durable-secret-provenance'
12+
import {
13+
isDurableSecretProvenanceEnforced,
14+
reportUnrecordedDurableProvenance,
15+
} from '@/lib/execution/durable-secret-provenance-enforcement'
1316
import {
1417
inspectPrivateSecretProvenanceRequest,
1518
isPrivateSecretProvenanceBundleV1,
@@ -22,10 +25,15 @@ import {
2225
import { readBoundMemorySecretProvenance } from '@/lib/memory/secret-provenance'
2326
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
2427

25-
const MAX_PRIVATE_MEMORY_CROSSINGS = 10_000
26-
const PRIVATE_MEMORY_QUERY_CHUNK_SIZE = 8
27-
const MAX_PRIVATE_MEMORY_PROVENANCE_ENTRIES = 10_000
28-
const MAX_PRIVATE_MEMORY_PROVENANCE_BYTES = 8 * 1024 * 1024
28+
/**
29+
* Ids per sidecar lookup, matching how the rest of the codebase chunks an `inArray`.
30+
*
31+
* It was 8, which only worked because a cap refused any read over ten thousand memories — at that
32+
* width the page loop would otherwise have issued more than a thousand sequential statements. The
33+
* cap is gone because refusing on a record count told us nothing about the data, so the page size
34+
* now has to be the thing that keeps the read bounded.
35+
*/
36+
const PRIVATE_MEMORY_QUERY_CHUNK_SIZE = 1_000
2937

3038
interface MemoryCrossing {
3139
id: string
@@ -97,52 +105,76 @@ export async function createMemoryResponse(options: {
97105
userId: options.userId,
98106
workspaceId: options.workspaceId,
99107
})
100-
if (options.memories.length > MAX_PRIVATE_MEMORY_CROSSINGS) {
101-
registry.markIncomplete('memory-crossing-capacity-exceeded')
102-
} else {
103-
const ids = [...new Set(options.memories.map((record) => record.id))]
104-
const memoriesById = new Map<string, MemoryCrossing[]>()
105-
for (const memory of options.memories) {
106-
const matching = memoriesById.get(memory.id) ?? []
107-
matching.push(memory)
108-
memoriesById.set(memory.id, matching)
109-
}
110-
let provenanceEntryCount = 0
111-
let provenanceBytes = 0
112-
for (let index = 0; index < ids.length; index += PRIVATE_MEMORY_QUERY_CHUNK_SIZE) {
113-
const pageIds = ids.slice(index, index + PRIVATE_MEMORY_QUERY_CHUNK_SIZE)
114-
const sidecars = await db
115-
.select()
116-
.from(memorySecretProvenance)
117-
.where(inArray(memorySecretProvenance.memoryId, pageIds))
118-
for (const sidecar of sidecars) {
119-
provenanceEntryCount += Array.isArray(sidecar.entries) ? sidecar.entries.length : 0
120-
provenanceBytes += Buffer.byteLength(JSON.stringify(sidecar.entries ?? []), 'utf8')
121-
}
122-
if (
123-
provenanceEntryCount > MAX_PRIVATE_MEMORY_PROVENANCE_ENTRIES ||
124-
provenanceBytes > MAX_PRIVATE_MEMORY_PROVENANCE_BYTES
125-
) {
126-
registry.markIncomplete('memory-crossing-capacity-exceeded')
127-
break
128-
}
129-
const sidecarById = new Map(sidecars.map((sidecar) => [sidecar.memoryId, sidecar]))
130-
for (const memoryId of pageIds) {
131-
for (const record of memoriesById.get(memoryId) ?? []) {
132-
const sidecar = sidecarById.get(record.id)
133-
const provenance = readBoundMemorySecretProvenance({
134-
secretProvenanceVersion: record.secretProvenanceVersion,
135-
data: record.data,
136-
provenanceContentHash: sidecar?.contentHash ?? null,
137-
status: sidecar?.status ?? null,
138-
entries: sidecar?.entries,
139-
})
140-
await importDurableSecretProvenance(registry, provenance, record.data, 'memory')
141-
}
108+
/**
109+
* No cap on how many memories may cross, and no separate accounting of what they carry.
110+
*
111+
* There was both: a refusal past ten thousand records, and a running total of every sidecar's
112+
* entries. The first said nothing about the data. The second summed entries *before* they were
113+
* folded, so a page of memories sharing a handful of secrets counted once per mention and could
114+
* refuse a read whose envelope would have held a dozen entries.
115+
*
116+
* Neither is needed, because the registry already bounds the only thing that has a real limit —
117+
* the serialized envelope — as each entry is added, at the granularity it actually dedupes on.
118+
* A second estimate in front of it could only ever be wrong in one of two directions.
119+
*/
120+
const ids = [...new Set(options.memories.map((record) => record.id))]
121+
const memoriesById = new Map<string, MemoryCrossing[]>()
122+
for (const memory of options.memories) {
123+
const matching = memoriesById.get(memory.id) ?? []
124+
matching.push(memory)
125+
memoriesById.set(memory.id, matching)
126+
}
127+
/**
128+
* Counted only while the surface is open. Under enforcement the import fails the registry closed
129+
* instead of proceeding, so counting those records would audit a fail-open read that never
130+
* happened — and this entry exists precisely to say a read went ahead unvouched.
131+
*/
132+
const memoryEnforced = isDurableSecretProvenanceEnforced('memory')
133+
let unrecordedMemoryCount = 0
134+
for (let index = 0; index < ids.length; index += PRIVATE_MEMORY_QUERY_CHUNK_SIZE) {
135+
const pageIds = ids.slice(index, index + PRIVATE_MEMORY_QUERY_CHUNK_SIZE)
136+
const sidecars = await db
137+
.select()
138+
.from(memorySecretProvenance)
139+
.where(inArray(memorySecretProvenance.memoryId, pageIds))
140+
const sidecarById = new Map(sidecars.map((sidecar) => [sidecar.memoryId, sidecar]))
141+
for (const memoryId of pageIds) {
142+
for (const record of memoriesById.get(memoryId) ?? []) {
143+
const sidecar = sidecarById.get(record.id)
144+
const provenance = readBoundMemorySecretProvenance({
145+
secretProvenanceVersion: record.secretProvenanceVersion,
146+
data: record.data,
147+
provenanceContentHash: sidecar?.contentHash ?? null,
148+
status: sidecar?.status ?? null,
149+
entries: sidecar?.entries,
150+
})
151+
if (provenance.status === 'unknown' && !memoryEnforced) unrecordedMemoryCount += 1
152+
await importDurableSecretProvenance(registry, provenance, record.data, 'memory', {
153+
reportUnrecorded: false,
154+
})
142155
}
143156
}
144157
}
145158

159+
/**
160+
* Counted here and reported once, rather than left to the per-record import.
161+
*
162+
* That import reports without a workspace, so the workspace-visible half of the trail never
163+
* reached the people it concerns — the audit entry is skipped when it cannot name one. Passing
164+
* the workspace down instead would have written one row per record, which on a wide read is
165+
* thousands of fire-and-forget inserts for a single event. One read is one thing that happened,
166+
* so it is one entry carrying how many records it covered — the shape the table surface uses.
167+
*/
168+
if (unrecordedMemoryCount > 0) {
169+
reportUnrecordedDurableProvenance({
170+
surface: 'memory',
171+
cause: 'durable-provenance-unknown',
172+
affectedCount: unrecordedMemoryCount,
173+
workspaceId: options.workspaceId,
174+
actorUserId: options.userId,
175+
})
176+
}
177+
146178
const envelope = serializePrivateToolMetadataResponseEnvelope(
147179
options.body,
148180
RESOLVED_SECRET_PROVENANCE_METADATA_V1,

apps/sim/content/blog/secret-provenance/index.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ Results returning from tools and Function blocks are checked before they become
6464

6565
Storage is the boundary that is easiest to miss. Workspace files, table cells, knowledge documents, and agent memory can all survive the execution that created them. Without durable provenance, a secret written during one run would look like ordinary data when another run read it back. Sim stores an encrypted provenance record and version marker alongside that content, allowing a later read to distinguish data known to be clean from data whose history is unknown.
6666

67-
When that history cannot be established, we do not treat `unknown` as `clean`. The payload is withheld, while safe parts of the record remain available: block identity, timing, status, and error type. A tool result can be kept out of model context without hiding the fact that the tool ran. The refusal also records where it happened and what made the registry incomplete, so failing closed does not mean debugging blind.
67+
When that history cannot be established, we do not treat `unknown` as `clean`. At a run's own boundaries the payload is withheld, while safe parts of the record remain available: block identity, timing, status, and error type. A tool result can be kept out of model context without hiding the fact that the tool ran. The refusal also records where it happened and what made the registry incomplete, so failing closed does not mean debugging blind.
68+
69+
Stored data is different, because a record written months ago outlives the run that could explain it. Refusing to read it would strand a team on its own tables and memory to protect against a risk we cannot confirm is there. So for that content the read proceeds and the workspace gets an audit entry naming the surface and what could not be vouched for. The judgment is deliberate: withholding is right when a run is asking to send something out, and visibility is right when the alternative is a workspace that can no longer read what it stored.
6870

6971
## Exact Matching, With One Limit
7072

apps/sim/executor/utils/resolved-secret-trace-registry.test.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,72 @@ describe('ResolvedSecretTraceRegistry', () => {
416416
})
417417
})
418418

419+
it('narrows each grouped export to its own root', () => {
420+
const scope = { userId: 'user-1', workspaceId: 'workspace-1' }
421+
const registry = new ResolvedSecretTraceRegistry(
422+
[
423+
{ name: 'FIRST', plaintext: 'alpha', encryptedValue: 'encrypted-first' },
424+
{ name: 'SECOND', plaintext: 'beta', encryptedValue: 'encrypted-second' },
425+
],
426+
scope
427+
)
428+
registry.recordResolvedAtInputPath('FIRST', 'alpha', ['rows', '0', 'a'])
429+
registry.recordResolvedAtInputPath('SECOND', 'beta', ['rows', '1', 'b'])
430+
registry.recordResolvedAtInputPath('FIRST', 'alpha', ['rows', '2', 'c', 'nested'])
431+
432+
const exported = registry.exportCommittedProvenanceForInputPathGroups([
433+
[['rows', '0', 'a']],
434+
[['rows', '1', 'b']],
435+
[['rows', '2', 'c']],
436+
[['rows']],
437+
[['rows', '3', 'untouched']],
438+
[],
439+
[
440+
['rows', '0', 'a'],
441+
['rows', '1', 'b'],
442+
],
443+
])
444+
445+
expect(exported.map((provenance) => provenance.entries)).toEqual([
446+
[{ name: 'FIRST', encryptedValue: 'encrypted-first' }],
447+
[{ name: 'SECOND', encryptedValue: 'encrypted-second' }],
448+
[{ name: 'FIRST', encryptedValue: 'encrypted-first' }],
449+
[
450+
{ name: 'FIRST', encryptedValue: 'encrypted-first' },
451+
{ name: 'SECOND', encryptedValue: 'encrypted-second' },
452+
],
453+
[],
454+
[],
455+
[
456+
{ name: 'FIRST', encryptedValue: 'encrypted-first' },
457+
{ name: 'SECOND', encryptedValue: 'encrypted-second' },
458+
],
459+
])
460+
expect(exported.every((provenance) => provenance.complete)).toBe(true)
461+
})
462+
463+
it('fails only the grouped exports an incomplete input path overlaps', async () => {
464+
const scope = { userId: 'user-1', workspaceId: 'workspace-1' }
465+
const registry = new ResolvedSecretTraceRegistry(
466+
[{ name: 'FIRST', plaintext: 'alpha', encryptedValue: 'encrypted-first' }],
467+
scope
468+
)
469+
registry.recordResolvedAtInputPath('FIRST', 'alpha', ['rows', '0', 'a'])
470+
await registry.importProvenanceForValueAtInputPath(null, 'alpha', ['rows', '1', 'b'], {
471+
trusted: false,
472+
})
473+
474+
const exported = registry.exportCommittedProvenanceForInputPathGroups([
475+
[['rows', '1', 'b']],
476+
[['rows', '1']],
477+
[['rows', '1', 'b', 'deeper']],
478+
[['rows', '0', 'a']],
479+
])
480+
481+
expect(exported.map((provenance) => provenance.complete)).toEqual([false, false, false, true])
482+
expect(exported[3].entries).toEqual([{ name: 'FIRST', encryptedValue: 'encrypted-first' }])
483+
})
484+
419485
it('fails closed when independent secret paths collapse into one transformed string', () => {
420486
const registry = new ResolvedSecretTraceRegistry([
421487
{ name: 'FIRST', plaintext: 'first', encryptedValue: 'encrypted-first' },
@@ -1598,8 +1664,6 @@ describe('incompleteness diagnostics', () => {
15981664
'client-tool-execution-untrusted',
15991665
'client-tool-content-unavailable',
16001666
'knowledge-result-provenance-unavailable',
1601-
'knowledge-response-capacity-exceeded',
1602-
'memory-crossing-capacity-exceeded',
16031667
'table-result-provenance-unavailable',
16041668
'mounted-file-provenance-unavailable',
16051669
'workspace-file-provenance-unknown',

0 commit comments

Comments
 (0)