diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index 8b32b5904f..2230190472 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -13,8 +13,15 @@ import { parseEvidence, immutableEvidence, withEvidenceCallerInputPath, + rebindEvidenceToTargetPath, } from "./evidence.js"; -import { MAX_JSON_DEPTH, type JsonValue } from "./jsonValue.js"; +import { + MAX_JSON_DEPTH, + jsonValueSchema, + type JsonValue, +} from "./jsonValue.js"; +import { AnalysisResourceConstraintError } from "./analysisErrorCore.js"; +import { projectAnalysisError } from "./analysisErrorProjection.js"; import { createEvidenceBundle } from "./evidenceBundle.js"; import { freezeJsonSnapshot } from "./immutableJson.js"; @@ -183,6 +190,49 @@ it("retains verbatim caller input without changing semantic identity or copying expect(() => withEvidenceCallerInputPath(second, "invalid\0path")).toThrow(); }); +it.each(["caller selection", "subject display"])( + "preserves sealed JSON ownership when rebinding %s metadata", + (metadata) => { + const original = createEvidence( + TARGET, + PROVIDER, + { + operation: "inspect", + parameters: {}, + result: { rows: [{ value: "observed" }] }, + rawResult: { source: ["provider"] }, + }, + { immutable: true }, + ); + const rebound = + metadata === "caller selection" + ? withEvidenceCallerInputPath(original, "./selected/../alias") + : rebindEvidenceToTargetPath(original, { + ...TARGET, + path: "/tmp/selected-alias", + }); + const projected = projectAnalysisError( + new AnalysisResourceConstraintError( + "inspect", + "memory", + "low heap", + null, + { + partialObservation: rebound, + }, + ), + ); + expect(rebound).not.toBe(original); + expect(rebound.evidence_id).toBe(original.evidence_id); + expect(rebound.normalized_result).toBe(original.normalized_result); + expect(rebound.raw_result).toBe(original.raw_result); + expect(projected.details?.partial_observation).toBe(rebound); + expect(jsonValueSchema.parse(rebound)).toBe(rebound); + expect(original.caller_input_path).toBeUndefined(); + expect(original.subject?.local_path).toBe(TARGET.path); + }, +); + it("applies the JSON depth boundary to owned immutable results", () => { let result: JsonValue = 1; for (let index = 0; index <= MAX_JSON_DEPTH; index += 1) diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index cc6895b15d..bcbc959386 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -26,6 +26,7 @@ import { freezeJsonSnapshot, freezeOwnedJsonTreeSnapshot, canBorrowParsedJsonSnapshot, + immutableJsonSnapshotMember, isImmutableJsonSnapshot, } from "./immutableJson.js"; import { digestSchema } from "./../domain/digests.js"; @@ -355,11 +356,7 @@ export const rebindEvidenceToTargetPath = ( // The original authenticated Evidence owns immutable payloads. Only the // small display subject and envelope are new, so preserve those payloads // without reparsing a potentially large analysis result. - Object.freeze(reboundSubject.digest); - Object.freeze(reboundSubject); - Object.freeze(reboundEvidence); - immutableEvidenceSnapshots.set(reboundEvidence, reboundEvidence); - return reboundEvidence; + return rememberReboundEvidence(evidence, reboundEvidence); }; /** Bind the exact selected request path without changing semantic identity or stored history. */ @@ -372,12 +369,23 @@ export const withEvidenceCallerInputPath = ( if (owned.caller_input_path === callerInputPath) return owned; // The authenticated source owns its immutable payloads. Only this small // envelope changes, so replay does not copy a document-sized result. - const rebound = Object.freeze({ + const rebound = { ...owned, caller_input_path: callerInputPath, - }); - immutableEvidenceSnapshots.set(rebound, rebound); - return rebound; + }; + return rememberReboundEvidence(owned, rebound); +}; + +/** Carry the source's sealing proof before sealing only the changed envelope. */ +const rememberReboundEvidence = ( + source: Evidence, + rebound: Evidence, +): Evidence => { + // Each own member belongs to an authenticated immutable source. Register + // these shared roots so sealing the envelope never walks their payloads. + for (const key of Object.keys(source)) + immutableJsonSnapshotMember(source, key); + return rememberImmutableEvidence(rebound); }; /** Recognize authenticated Evidence whose complete reachable JSON data is immutable. */