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.
This commit is contained in:
Vikhyath Mondreti
2026-08-27 14:00:07 -07:00
committed by GitHub
parent 5682c6cda3
commit b6a235cc9e
2 changed files with 96 additions and 15 deletions
@@ -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])
+21 -15
View File
@@ -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 {