Skip to content

Commit d9f727a

Browse files
committed
improvement(provenance): classify every unusable stored envelope at the display boundary
Review findings from the first round, both accepted: a present-but- malformed block or run envelope was withheld with no attributed line, and a complete envelope whose entries fail decryption latched the staged registry with only the unattributed entry-level error. Fault classification moves into the one import helper the display paths share, which now returns the registry and the fault together: absent is not a fault, unparseable is malformed, unable-to-vouch is incomplete, and a complete envelope whose registry latched during import — entry decryption is the only latch on that trusted path — is undecryptable. Every consumer reports through the same table, severity per kind, so the exact-value loop stops being the only site that could name a malformed envelope. Withholding behavior is unchanged at every site.
1 parent 1143254 commit d9f727a

2 files changed

Lines changed: 152 additions & 70 deletions

File tree

apps/sim/lib/logs/execution/trace-store.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,74 @@ describe('stored provenance display reporting', () => {
616616
)
617617
})
618618

619+
/** A complete envelope whose entries cannot be decrypted withholds content like any fault. */
620+
it('attributes an undecryptable stored envelope to its execution at error', async () => {
621+
decryptSecretMock.mockRejectedValue(new Error('key rotated'))
622+
623+
const displayData = await projectExecutionDataForDisplay(
624+
{
625+
finalOutput: { result: 'value' },
626+
executionState: {
627+
resolvedSecretTraceProvenance: {
628+
version: 1,
629+
complete: true,
630+
entries: [],
631+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
632+
},
633+
finalOutputResolvedSecretTraceProvenance: {
634+
version: 1,
635+
complete: true,
636+
entries: [{ name: 'SECRET', encryptedValue: 'ciphertext' }],
637+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
638+
},
639+
},
640+
},
641+
CONTEXT
642+
)
643+
644+
expect(displayData).not.toHaveProperty('finalOutput')
645+
expect(registrySummaryLines()).toHaveLength(0)
646+
expect(mockLogger.error).toHaveBeenCalledWith(
647+
'Stored execution provenance could not be decrypted',
648+
expect.objectContaining({
649+
site: 'traceStore.displayProjection',
650+
executionId: 'execution-1',
651+
parts: ['finalOutput'],
652+
})
653+
)
654+
})
655+
656+
it('reports a malformed block-output envelope at error, withholding the output', async () => {
657+
const result = await materializeExecutionDataForDisplayWithBlockOutputs(
658+
{
659+
executionState: {
660+
resolvedSecretTraceProvenance: {
661+
version: 1,
662+
complete: true,
663+
entries: [],
664+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
665+
},
666+
blockStates: {
667+
'block-1': { output: { value: 1 }, resolvedSecretTraceProvenance: 'garbage' },
668+
},
669+
},
670+
},
671+
CONTEXT,
672+
['block-1']
673+
)
674+
675+
expect(result.blockOutputs.has('block-1')).toBe(false)
676+
expect(registrySummaryLines()).toHaveLength(0)
677+
expect(mockLogger.error).toHaveBeenCalledWith(
678+
'Stored execution provenance is malformed',
679+
expect.objectContaining({
680+
site: 'traceStore.blockOutputs',
681+
executionId: 'execution-1',
682+
parts: ['blockOutput:block-1'],
683+
})
684+
)
685+
})
686+
619687
it('stays silent when every stored envelope is complete', async () => {
620688
const displayData = await projectExecutionDataForDisplay(
621689
{

apps/sim/lib/logs/execution/trace-store.ts

Lines changed: 84 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -300,36 +300,29 @@ export async function materializeExecutionDataForDisplayWithBlockOutputs(
300300
return { executionData: displayData, blockOutputs: new Map() }
301301
}
302302

303-
const runProvenance =
304-
materialized[RESOLVED_SECRET_PROVENANCE_KEY] ?? executionState?.[RESOLVED_SECRET_PROVENANCE_KEY]
305-
const runRegistry = await importResolvedSecretTraceRegistry(
306-
runProvenance,
303+
const runImport = await importStoredDisplayEnvelope(
304+
materialized[RESOLVED_SECRET_PROVENANCE_KEY] ??
305+
executionState?.[RESOLVED_SECRET_PROVENANCE_KEY],
307306
'traceStore.blockOutputRunProvenance'
308307
)
309308
const provenanceFaults = new Map<string, StoredDisplayProvenanceFault>()
310-
if (isIncompleteStoredEnvelope(runProvenance)) {
311-
provenanceFaults.set('run', 'incomplete')
312-
}
309+
if (runImport.fault) provenanceFaults.set('run', runImport.fault)
313310
const blockOutputs = new Map<string, unknown>()
314311
const projectionStore = createReadOnlyProjectionStore(context)
315312

316313
for (const blockId of new Set(blockIds)) {
317314
const blockState = readRecord(blockStates[blockId])
318315
if (!blockState || blockState.output === undefined) continue
319316

320-
const hasExactProvenance = Object.hasOwn(blockState, RESOLVED_SECRET_PROVENANCE_KEY)
321-
if (
322-
hasExactProvenance &&
323-
isIncompleteStoredEnvelope(blockState[RESOLVED_SECRET_PROVENANCE_KEY])
324-
) {
325-
provenanceFaults.set(`blockOutput:${blockId}`, 'incomplete')
317+
let registry = runImport.registry
318+
if (Object.hasOwn(blockState, RESOLVED_SECRET_PROVENANCE_KEY)) {
319+
const blockImport = await importStoredDisplayEnvelope(
320+
blockState[RESOLVED_SECRET_PROVENANCE_KEY],
321+
'traceStore.blockOutputExactProvenance'
322+
)
323+
if (blockImport.fault) provenanceFaults.set(`blockOutput:${blockId}`, blockImport.fault)
324+
registry = blockImport.registry
326325
}
327-
const registry = hasExactProvenance
328-
? await importResolvedSecretTraceRegistry(
329-
blockState[RESOLVED_SECRET_PROVENANCE_KEY],
330-
'traceStore.blockOutputExactProvenance'
331-
)
332-
: runRegistry
333326
const now = new Date().toISOString()
334327
const [projected] = await projectTraceSpansForSecrets(
335328
[
@@ -358,73 +351,96 @@ function readRecord(value: unknown): Record<string, unknown> | undefined {
358351
return isRecordLike(value) ? (value as Record<string, unknown>) : undefined
359352
}
360353

354+
type StoredDisplayProvenanceFault = 'incomplete' | 'malformed' | 'undecryptable'
355+
356+
interface StoredDisplayEnvelopeImport {
357+
registry: ResolvedSecretTraceRegistry | undefined
358+
fault: StoredDisplayProvenanceFault | undefined
359+
}
360+
361361
/**
362362
* Staged: display registries filter stored values for one materialization and are discarded, and
363363
* their own mark-time summaries name no execution — the read boundary reports instead, through
364364
* {@link reportStoredDisplayProvenanceFaults}. A stored envelope's incompleteness is not an event
365365
* on this path; it was recorded when the run wrote it, and every later view re-derives it.
366+
*
367+
* The fault is classified where the import happens so every consumer reports the same way: an
368+
* absent envelope is not a fault (truncation has its own warning), a present value that does not
369+
* parse is `malformed`, a parsed envelope that cannot vouch is `incomplete`, and a complete
370+
* envelope whose registry latched during import — entry decryption is the only latch on this
371+
* trusted path — is `undecryptable`. Projection withholds the guarded values in all three cases.
366372
*/
367-
async function importResolvedSecretTraceRegistry(
373+
async function importStoredDisplayEnvelope(
368374
provenance: unknown,
369375
origin: string
370-
): Promise<ResolvedSecretTraceRegistry | undefined> {
371-
if (!isResolvedSecretTraceProvenanceV1(provenance)) return undefined
376+
): Promise<StoredDisplayEnvelopeImport> {
377+
if (provenance === undefined) return { registry: undefined, fault: undefined }
378+
if (!isResolvedSecretTraceProvenanceV1(provenance)) {
379+
return { registry: undefined, fault: 'malformed' }
380+
}
372381

373382
const registry = new ResolvedSecretTraceRegistry([], provenance.scope, { staged: true })
374383
await registry.importProvenance(provenance, { trusted: true, origin })
375-
return registry
376-
}
377-
378-
type StoredDisplayProvenanceFault = 'incomplete' | 'malformed'
379-
380-
/** True for a stored envelope that parses but cannot vouch for the value it accompanies. */
381-
function isIncompleteStoredEnvelope(value: unknown): boolean {
382-
return isResolvedSecretTraceProvenanceV1(value) && !value.complete
384+
const fault = !provenance.complete
385+
? 'incomplete'
386+
: registry.isPermanentlyIncomplete()
387+
? 'undecryptable'
388+
: undefined
389+
return { registry, fault }
383390
}
384391

385392
const MAX_REPORTED_PROVENANCE_FAULT_PARTS = 20
386393

394+
const STORED_PROVENANCE_FAULT_REPORTS = {
395+
incomplete: {
396+
level: 'warn',
397+
message: 'Stored execution provenance cannot vouch for display content',
398+
},
399+
malformed: { level: 'error', message: 'Stored execution provenance is malformed' },
400+
/** The entry-level decrypt error already logs its counts; this adds the execution it hit. */
401+
undecryptable: { level: 'error', message: 'Stored execution provenance could not be decrypted' },
402+
} as const satisfies Record<
403+
StoredDisplayProvenanceFault,
404+
{ level: 'warn' | 'error'; message: string }
405+
>
406+
387407
/**
388-
* One attributed line per display function per materialization, in place of one registry summary
389-
* per envelope per view.
408+
* One attributed line per fault kind per display function, in place of one registry summary per
409+
* envelope per view.
390410
*
391411
* The registry summaries these replace carried counts and a workspace but no execution id, so a
392412
* reader repeatedly materializing the same stored rows produced an unattributable stream — the
393-
* lines could not say which executions to go look at. Incomplete stays at warn (a stored state
394-
* being re-read); malformed stays at error (a stored envelope that cannot be parsed is a fault
395-
* wherever it is met, matching the level its registry reason carries elsewhere).
413+
* lines could not say which executions to go look at. Severity follows the registry reason each
414+
* fault replaces: incomplete at warn (a stored state being re-read), malformed and undecryptable
415+
* at error (faults wherever they are met).
396416
*
397-
* A block-outputs read runs the display projection first, so an incomplete run envelope appears
398-
* once under each site — `traceSpans` guarding the span projection, `run` as the block fallback.
399-
* Two sites reading the same envelope are two facts about the view; collapsing them would couple
400-
* the display functions to share reporting state for one line less.
417+
* A block-outputs read runs the display projection first, so a faulted run envelope appears once
418+
* under each site — `traceSpans` guarding the span projection, `run` as the block fallback. Two
419+
* sites reading the same envelope are two facts about the view; collapsing them would couple the
420+
* display functions to share reporting state for one line less.
401421
*/
402422
function reportStoredDisplayProvenanceFaults(
403423
site: string,
404424
context: TraceStoreReadContext,
405425
faults: ReadonlyMap<string, StoredDisplayProvenanceFault>
406426
): void {
407427
if (faults.size === 0) return
408-
const partsByFault = { incomplete: [] as string[], malformed: [] as string[] }
409-
for (const [part, fault] of faults) partsByFault[fault].push(part)
410428
const details = {
411429
site,
412430
executionId: context.executionId,
413431
...(context.workflowId ? { workflowId: context.workflowId } : {}),
414432
...(context.workspaceId ? { workspaceId: context.workspaceId } : {}),
415433
}
416-
if (partsByFault.incomplete.length > 0) {
417-
logger.warn('Stored execution provenance cannot vouch for display content', {
434+
for (const [kind, report] of Object.entries(STORED_PROVENANCE_FAULT_REPORTS) as [
435+
StoredDisplayProvenanceFault,
436+
(typeof STORED_PROVENANCE_FAULT_REPORTS)[StoredDisplayProvenanceFault],
437+
][]) {
438+
const parts = [...faults].filter(([, fault]) => fault === kind).map(([part]) => part)
439+
if (parts.length === 0) continue
440+
logger[report.level](report.message, {
418441
...details,
419-
parts: partsByFault.incomplete.slice(0, MAX_REPORTED_PROVENANCE_FAULT_PARTS),
420-
partCount: partsByFault.incomplete.length,
421-
})
422-
}
423-
if (partsByFault.malformed.length > 0) {
424-
logger.error('Stored execution provenance is malformed', {
425-
...details,
426-
parts: partsByFault.malformed.slice(0, MAX_REPORTED_PROVENANCE_FAULT_PARTS),
427-
partCount: partsByFault.malformed.length,
442+
parts: parts.slice(0, MAX_REPORTED_PROVENANCE_FAULT_PARTS),
443+
partCount: parts.length,
428444
})
429445
}
430446
}
@@ -467,7 +483,10 @@ export async function projectExecutionDataForDisplay(
467483
return projectLegacyExecutionDataForDisplay(executionData)
468484
}
469485

470-
const registry = await importResolvedSecretTraceRegistry(provenance, 'traceStore.spanProvenance')
486+
const provenanceFaults = new Map<string, StoredDisplayProvenanceFault>()
487+
const runImport = await importStoredDisplayEnvelope(provenance, 'traceStore.spanProvenance')
488+
const registry = runImport.registry
489+
if (runImport.fault) provenanceFaults.set('traceSpans', runImport.fault)
471490

472491
/**
473492
* Compaction drops `executionState`, and with it the only copy of the
@@ -498,11 +517,6 @@ export async function projectExecutionDataForDisplay(
498517

499518
const projectionStore = createReadOnlyProjectionStore(context)
500519

501-
const provenanceFaults = new Map<string, StoredDisplayProvenanceFault>()
502-
if (isIncompleteStoredEnvelope(provenance)) {
503-
provenanceFaults.set('traceSpans', 'incomplete')
504-
}
505-
506520
const exactValueProjections = new Map<string, unknown>()
507521
for (const [valueKey, provenanceKey] of Object.entries(EXACT_LOG_VALUE_PROVENANCE_KEYS)) {
508522
if (
@@ -513,18 +527,18 @@ export async function projectExecutionDataForDisplay(
513527
continue
514528
}
515529

516-
const exactProvenance = executionState[provenanceKey]
517-
const exactRegistry = isResolvedSecretTraceProvenanceV1(exactProvenance)
518-
? new ResolvedSecretTraceRegistry([], exactProvenance.scope, { staged: true })
519-
: new ResolvedSecretTraceRegistry([], undefined, { staged: true })
520-
if (isResolvedSecretTraceProvenanceV1(exactProvenance)) {
521-
if (!exactProvenance.complete) provenanceFaults.set(valueKey, 'incomplete')
522-
await exactRegistry.importProvenance(exactProvenance, {
523-
trusted: true,
524-
origin: 'traceStore.exactProvenance',
525-
})
526-
} else {
527-
provenanceFaults.set(valueKey, 'malformed')
530+
const exactImport = await importStoredDisplayEnvelope(
531+
executionState[provenanceKey],
532+
'traceStore.exactProvenance'
533+
)
534+
if (exactImport.fault) provenanceFaults.set(valueKey, exactImport.fault)
535+
/**
536+
* The exact value must project against SOME registry, so an unusable envelope gets a latched
537+
* one — the projection then withholds the value rather than passing it through unguarded.
538+
*/
539+
let exactRegistry = exactImport.registry
540+
if (!exactRegistry) {
541+
exactRegistry = new ResolvedSecretTraceRegistry([], undefined, { staged: true })
528542
exactRegistry.markIncomplete('untrusted-provenance', { origin: 'traceStore.exactProvenance' })
529543
}
530544

0 commit comments

Comments
 (0)