diff --git a/src/application/ProviderOperationExecution.test.ts b/src/application/ProviderOperationExecution.test.ts index 8da9d83cf2..7d456e9d19 100644 --- a/src/application/ProviderOperationExecution.test.ts +++ b/src/application/ProviderOperationExecution.test.ts @@ -136,3 +136,25 @@ it("clears a non-JSON partial from the cleanup primary projection returns after "partial_observation", ); }); + +it("freezes a retained partial so a later mutation cannot break projection", () => { + const partial = { steps: [] as unknown[] }; + const primary = new AnalysisCancelledError("capture_browser_scenario", { + cause: { requested: "stop during capture" }, + partialObservation: partial as never, + }); + keepIncompleteObservation( + primary, + "capture_browser_scenario", + "unused because the partial is JSON", + ); + partial.steps.push(undefined); + + expect(primary.partialObservation).toEqual({ steps: [] }); + expect(Object.isFrozen(primary.partialObservation)).toBe(true); + expect(primary.secondaryFailures).toHaveLength(0); + expect(projectAnalysisError(primary)).toMatchObject({ + code: "cancelled", + details: { partial_observation: { steps: [] } }, + }); +}); diff --git a/src/application/ProviderOperationExecution.ts b/src/application/ProviderOperationExecution.ts index 60e196a3a6..924f33a9ff 100644 --- a/src/application/ProviderOperationExecution.ts +++ b/src/application/ProviderOperationExecution.ts @@ -9,7 +9,14 @@ import { AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import type { Evidence } from "../domain/evidence.js"; -import { jsonValueValidationIssue } from "../domain/jsonValue.js"; +import { + freezeJsonSnapshot, + isImmutableJsonSnapshot, +} from "../domain/immutableJson.js"; +import { + jsonValueSchema, + jsonValueValidationIssue, +} from "../domain/jsonValue.js"; import { projectInputIssues } from "../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -97,6 +104,8 @@ export const completeProviderEvidence = ( /** * Retain an incomplete observation when projection can serialize it. + * A JSON partial is replaced by one frozen snapshot, so a later mutation + * cannot make projection throw and projection does not copy it again. * A partial that is not JSON is cleared from the returned error and from * every cleanup primary projection can select later. Each cleared error * records an output failure, leaving cancellation or timeout primary. @@ -111,15 +120,18 @@ export const keepIncompleteObservation = ( while (current !== undefined && !seen.has(current)) { seen.add(current); const partial = current.partialObservation; - // Validate without cloning. Projection serializes a kept partial later. - if ( - partial !== undefined && - jsonValueValidationIssue(partial) !== undefined - ) { - current.partialObservation = undefined; - current.retainSecondaryFailure( - new AnalysisOutputError(operation, reason), - ); + if (partial !== undefined && !isImmutableJsonSnapshot(partial)) { + // Validate without cloning. Copy only a partial that projection keeps. + if (jsonValueValidationIssue(partial) === undefined) { + current.partialObservation = freezeJsonSnapshot( + jsonValueSchema.parse(partial), + ) as typeof partial; + } else { + current.partialObservation = undefined; + current.retainSecondaryFailure( + new AnalysisOutputError(operation, reason), + ); + } } current = current.cleanupPrimary; } diff --git a/src/application/ownerObservationExpectation.fixture.ts b/src/application/ownerObservationExpectation.fixture.ts index d466c57812..f71a73e49c 100644 --- a/src/application/ownerObservationExpectation.fixture.ts +++ b/src/application/ownerObservationExpectation.fixture.ts @@ -22,7 +22,7 @@ export const expectKeptPartial = ( partial: unknown, code: "cancelled" | "provider_timeout" = "cancelled", ): void => { - expect(error.partialObservation).toBe(partial); + expect(error.partialObservation).toEqual(partial); expect(error.secondaryFailures).toHaveLength(0); expect(projectAnalysisError(error)).toMatchObject({ code,