Skip to content

Commit b6a235c

Browse files
fix(provenance): derive final-output provenance instead of declaring it unvouchable (#7173)
A run whose last block stored its output without provenance had its final-output envelope stamped incomplete. That is not what an absent block-state envelope means: several state writers legitimately omit one, and a subflow sentinel aggregating iteration results is the common case — a loop that ran no iterations has nothing to merge, so its aggregate carries none. The workflows hitting this in production end in a loop. The cost was not noise. An incomplete final-output envelope withholds finalOutput from the execution log, so the author of the workflow could not see their own run's output, on every view, forever — and each view re-derived the same verdict, which is the read-path re-fire now dominating the provenance stream. Derive from the run registry against the value being described, which is what the end-of-run path already did and what every other consumer of a provenance-less block state does. The two now share one derivation rather than disagreeing: a shortcut when the block state has an exact envelope, the registry otherwise. It fails closed on its own terms — a latched registry still exports incomplete — and the guard for that is pinned by its own test.
1 parent 5682c6c commit b6a235c

2 files changed

Lines changed: 96 additions & 15 deletions

File tree

apps/sim/executor/execution/engine.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,81 @@ describe('ExecutionEngine', () => {
233233
expect(result.executionState?.finalOutputResolvedSecretTraceProvenance?.entries).toEqual([])
234234
})
235235

236+
/**
237+
* A subflow sentinel stores its aggregate without provenance, and a loop that ran no
238+
* iterations has none to merge. Treating that absence as a verdict marked the run
239+
* unvouchable, which withheld the user's own final output from their execution log on every
240+
* later view.
241+
*/
242+
it('derives final output provenance when the last block state carries none', async () => {
243+
const node = createMockNode('loop-1', 'loop')
244+
const registry = new ResolvedSecretTraceRegistry([
245+
{ name: 'TOKEN', plaintext: 'secret-value-1234', encryptedValue: 'ciphertext' },
246+
])
247+
registry.recordResolved('TOKEN', 'secret-value-1234')
248+
const context = createMockContext({
249+
decisions: { router: new Map(), condition: new Map() },
250+
resolvedSecretTraceRegistry: registry,
251+
})
252+
const nodeOrchestrator = createMockNodeOrchestrator()
253+
vi.mocked(nodeOrchestrator.executeNode).mockResolvedValue({
254+
nodeId: node.id,
255+
output: { results: ['secret-value-1234'] },
256+
isFinalOutput: true,
257+
})
258+
vi.mocked(nodeOrchestrator.handleNodeCompletion).mockImplementation(
259+
(_ctx, nodeId, output) => {
260+
context.blockStates.set(nodeId, { output, executed: true, executionTime: 1 })
261+
}
262+
)
263+
264+
const engine = new ExecutionEngine(
265+
context,
266+
createMockDAG([node]),
267+
createMockEdgeManager(),
268+
nodeOrchestrator
269+
)
270+
const result = await engine.run(node.id)
271+
272+
const provenance = result.executionState?.finalOutputResolvedSecretTraceProvenance
273+
expect(provenance?.complete).toBe(true)
274+
expect(provenance?.entries).toEqual([{ name: 'TOKEN', encryptedValue: 'ciphertext' }])
275+
})
276+
277+
/** Deriving must not weaken the guarantee: a latched registry still exports incomplete. */
278+
it('keeps the final output envelope incomplete when the registry latched', async () => {
279+
const node = createMockNode('loop-1', 'loop')
280+
const registry = new ResolvedSecretTraceRegistry([
281+
{ name: 'TOKEN', plaintext: 'secret-value-1234', encryptedValue: 'ciphertext' },
282+
])
283+
registry.markIncomplete('unspecified')
284+
const context = createMockContext({
285+
decisions: { router: new Map(), condition: new Map() },
286+
resolvedSecretTraceRegistry: registry,
287+
})
288+
const nodeOrchestrator = createMockNodeOrchestrator()
289+
vi.mocked(nodeOrchestrator.executeNode).mockResolvedValue({
290+
nodeId: node.id,
291+
output: { results: ['secret-value-1234'] },
292+
isFinalOutput: true,
293+
})
294+
vi.mocked(nodeOrchestrator.handleNodeCompletion).mockImplementation(
295+
(_ctx, nodeId, output) => {
296+
context.blockStates.set(nodeId, { output, executed: true, executionTime: 1 })
297+
}
298+
)
299+
300+
const engine = new ExecutionEngine(
301+
context,
302+
createMockDAG([node]),
303+
createMockEdgeManager(),
304+
nodeOrchestrator
305+
)
306+
const result = await engine.run(node.id)
307+
308+
expect(result.executionState?.finalOutputResolvedSecretTraceProvenance?.complete).toBe(false)
309+
})
310+
236311
it('should not fall back to starter blocks for terminal resume snapshots', async () => {
237312
const startNode = createMockNode('start', 'starter')
238313
const dag = createMockDAG([startNode])

apps/sim/executor/execution/engine.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -505,25 +505,31 @@ export class ExecutionEngine {
505505
this.context.finalOutputResolvedSecretTraceProvenance = state.resolvedSecretTraceProvenance
506506
return
507507
}
508+
/**
509+
* A block state without provenance is an absence of a shortcut, not a verdict. Several state
510+
* writers legitimately store an output without one — a subflow sentinel aggregating iteration
511+
* results is the common case, and a loop that ran no iterations has nothing to merge — so
512+
* stamping an incomplete envelope here declared the run unvouchable whenever the last block
513+
* was one of them. Every other consumer of a provenance-less block state falls back to the run
514+
* registry; deriving does the same, against the value actually being described.
515+
*/
516+
this.deriveFinalOutputProvenance()
517+
}
508518

509-
if (this.context.resolvedSecretTraceRegistry) {
510-
this.context.finalOutputResolvedSecretTraceProvenance = {
511-
version: 1,
512-
complete: false,
513-
entries: [],
514-
}
515-
}
519+
/**
520+
* Derives the final-output envelope from the run registry. Fails closed on its own terms: a
521+
* latched registry exports an incomplete envelope, which is the genuinely unvouchable case.
522+
*/
523+
private deriveFinalOutputProvenance(): void {
524+
const registry = this.context.resolvedSecretTraceRegistry
525+
if (!registry) return
526+
this.context.finalOutputResolvedSecretTraceProvenance =
527+
registry.exportCommittedProvenanceForValue(this.finalOutput)
516528
}
517529

518530
private ensureFinalOutputProvenance(): void {
519-
if (
520-
Object.hasOwn(this.context, 'finalOutputResolvedSecretTraceProvenance') ||
521-
!this.context.resolvedSecretTraceRegistry
522-
) {
523-
return
524-
}
525-
this.context.finalOutputResolvedSecretTraceProvenance =
526-
this.context.resolvedSecretTraceRegistry.exportCommittedProvenanceForValue(this.finalOutput)
531+
if (Object.hasOwn(this.context, 'finalOutputResolvedSecretTraceProvenance')) return
532+
this.deriveFinalOutputProvenance()
527533
}
528534

529535
private buildPausedResult(startTime: number): ExecutionResult {

0 commit comments

Comments
 (0)