From 6e54c1b6469403d4334a3222f15d6932232100d8 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 27 Aug 2026 13:37:43 -0700 Subject: [PATCH] fix(provenance): derive final-output provenance instead of declaring it unvouchable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/executor/execution/engine.test.ts | 75 ++++++++++++++++++++++ apps/sim/executor/execution/engine.ts | 36 ++++++----- 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/apps/sim/executor/execution/engine.test.ts b/apps/sim/executor/execution/engine.test.ts index 611bfa32f74..2e4d4291528 100644 --- a/apps/sim/executor/execution/engine.test.ts +++ b/apps/sim/executor/execution/engine.test.ts @@ -233,6 +233,81 @@ describe('ExecutionEngine', () => { expect(result.executionState?.finalOutputResolvedSecretTraceProvenance?.entries).toEqual([]) }) + /** + * A subflow sentinel stores its aggregate without provenance, and a loop that ran no + * iterations has none to merge. Treating that absence as a verdict marked the run + * unvouchable, which withheld the user's own final output from their execution log on every + * later view. + */ + it('derives final output provenance when the last block state carries none', async () => { + const node = createMockNode('loop-1', 'loop') + const registry = new ResolvedSecretTraceRegistry([ + { name: 'TOKEN', plaintext: 'secret-value-1234', encryptedValue: 'ciphertext' }, + ]) + registry.recordResolved('TOKEN', 'secret-value-1234') + const context = createMockContext({ + decisions: { router: new Map(), condition: new Map() }, + resolvedSecretTraceRegistry: registry, + }) + const nodeOrchestrator = createMockNodeOrchestrator() + vi.mocked(nodeOrchestrator.executeNode).mockResolvedValue({ + nodeId: node.id, + output: { results: ['secret-value-1234'] }, + isFinalOutput: true, + }) + vi.mocked(nodeOrchestrator.handleNodeCompletion).mockImplementation( + (_ctx, nodeId, output) => { + context.blockStates.set(nodeId, { output, executed: true, executionTime: 1 }) + } + ) + + const engine = new ExecutionEngine( + context, + createMockDAG([node]), + createMockEdgeManager(), + nodeOrchestrator + ) + const result = await engine.run(node.id) + + const provenance = result.executionState?.finalOutputResolvedSecretTraceProvenance + expect(provenance?.complete).toBe(true) + expect(provenance?.entries).toEqual([{ name: 'TOKEN', encryptedValue: 'ciphertext' }]) + }) + + /** Deriving must not weaken the guarantee: a latched registry still exports incomplete. */ + it('keeps the final output envelope incomplete when the registry latched', async () => { + const node = createMockNode('loop-1', 'loop') + const registry = new ResolvedSecretTraceRegistry([ + { name: 'TOKEN', plaintext: 'secret-value-1234', encryptedValue: 'ciphertext' }, + ]) + registry.markIncomplete('unspecified') + const context = createMockContext({ + decisions: { router: new Map(), condition: new Map() }, + resolvedSecretTraceRegistry: registry, + }) + const nodeOrchestrator = createMockNodeOrchestrator() + vi.mocked(nodeOrchestrator.executeNode).mockResolvedValue({ + nodeId: node.id, + output: { results: ['secret-value-1234'] }, + isFinalOutput: true, + }) + vi.mocked(nodeOrchestrator.handleNodeCompletion).mockImplementation( + (_ctx, nodeId, output) => { + context.blockStates.set(nodeId, { output, executed: true, executionTime: 1 }) + } + ) + + const engine = new ExecutionEngine( + context, + createMockDAG([node]), + createMockEdgeManager(), + nodeOrchestrator + ) + const result = await engine.run(node.id) + + expect(result.executionState?.finalOutputResolvedSecretTraceProvenance?.complete).toBe(false) + }) + it('should not fall back to starter blocks for terminal resume snapshots', async () => { const startNode = createMockNode('start', 'starter') const dag = createMockDAG([startNode]) diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index 15cd56ea593..acfa7cfa42b 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -505,25 +505,31 @@ export class ExecutionEngine { this.context.finalOutputResolvedSecretTraceProvenance = state.resolvedSecretTraceProvenance return } + /** + * A block state without provenance is an absence of a shortcut, not a verdict. Several state + * writers legitimately store an output without one — a subflow sentinel aggregating iteration + * results is the common case, and a loop that ran no iterations has nothing to merge — so + * stamping an incomplete envelope here declared the run unvouchable whenever the last block + * was one of them. Every other consumer of a provenance-less block state falls back to the run + * registry; deriving does the same, against the value actually being described. + */ + this.deriveFinalOutputProvenance() + } - if (this.context.resolvedSecretTraceRegistry) { - this.context.finalOutputResolvedSecretTraceProvenance = { - version: 1, - complete: false, - entries: [], - } - } + /** + * Derives the final-output envelope from the run registry. Fails closed on its own terms: a + * latched registry exports an incomplete envelope, which is the genuinely unvouchable case. + */ + private deriveFinalOutputProvenance(): void { + const registry = this.context.resolvedSecretTraceRegistry + if (!registry) return + this.context.finalOutputResolvedSecretTraceProvenance = + registry.exportCommittedProvenanceForValue(this.finalOutput) } private ensureFinalOutputProvenance(): void { - if ( - Object.hasOwn(this.context, 'finalOutputResolvedSecretTraceProvenance') || - !this.context.resolvedSecretTraceRegistry - ) { - return - } - this.context.finalOutputResolvedSecretTraceProvenance = - this.context.resolvedSecretTraceRegistry.exportCommittedProvenanceForValue(this.finalOutput) + if (Object.hasOwn(this.context, 'finalOutputResolvedSecretTraceProvenance')) return + this.deriveFinalOutputProvenance() } private buildPausedResult(startTime: number): ExecutionResult {