diff --git a/src/capability-model/evolution.ts b/src/capability-model/evolution.ts index 32e0cf0..f6ec15a 100644 --- a/src/capability-model/evolution.ts +++ b/src/capability-model/evolution.ts @@ -26,43 +26,80 @@ export const contentDigest = (value: unknown) => * event loop while verifying large immutable metadata. No validation is skipped. * This is for asynchronous loading, not a different build/wire identity. */ export async function contentDigestAsync(value: unknown): Promise { - function* parts(value: unknown): Generator { - if (Array.isArray(value)) { - yield "["; - for (let index = 0; index < value.length; index++) { - if (index) yield ","; - // Match canonicalJson's Array.map behavior for sparse arrays too. - if (index in value) yield* parts(value[index]); - } - yield "]"; - } else if (value !== null && typeof value === "object") { - if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) - throw new Error("Expected a plain JSON object"); - yield "{"; - let first = true; - for (const key of Object.keys(value).sort(compareText)) { - const entry = (value as Record)[key]; - if (entry === undefined) continue; - if (!first) yield ","; - first = false; - yield JSON.stringify(key) + ":"; - yield* parts(entry); - } - yield "}"; - } else yield canonicalJson(value); - } - const hash = createHash("sha256"); + // An explicit stack visits each node once; recursive generator delegation + // otherwise forwards every token through every ancestor of a deep plan. + type Frame = { kind: "value" | "array" | "object"; value: unknown; index?: number; keys?: string[]; wrote?: boolean }; + const hash = createHash("sha256"), + stack: Frame[] = [{ kind: "value", value }], + active = new WeakSet(); let buffer = "", - sliceStarted = performance.now(); - for (const part of parts(value)) { + sliceStarted = performance.now(), + steps = 0; + const append = (part: string) => { buffer += part; if (buffer.length >= 65536) { hash.update(buffer); buffer = ""; - if (performance.now() - sliceStarted >= 8) { - await setImmediate(); - sliceStarted = performance.now(); + } + }; + while (stack.length) { + const frame = stack[stack.length - 1]!; + if (frame.kind === "value") { + const value = frame.value; + if (value !== null && typeof value === "object") { + if (active.has(value)) throw new Error("Cannot hash cyclic JSON values"); + active.add(value); + frame.index = 0; + if (Array.isArray(value)) { + frame.kind = "array"; + append("["); + } else { + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) + throw new Error("Expected a plain JSON object"); + frame.kind = "object"; + frame.keys = Object.keys(value).sort(compareText); + frame.wrote = false; + append("{"); + } + } else { + append(canonicalJson(value)); + stack.pop(); } + } else if (frame.kind === "array") { + const array = frame.value as unknown[]; + if (frame.index === array.length) { + append("]"); + active.delete(array); + stack.pop(); + } else { + const index = frame.index!; + frame.index = index + 1; + if (index) append(","); + // Match canonicalJson's Array.map behavior for sparse arrays too. + if (index in array) stack.push({ kind: "value", value: array[index] }); + } + } else { + const object = frame.value as Record; + if (frame.index === frame.keys!.length) { + append("}"); + active.delete(object); + stack.pop(); + } else { + const index = frame.index!; + frame.index = index + 1; + const key = frame.keys![index]!, + entry = object[key]; + if (entry !== undefined) { + if (frame.wrote) append(","); + frame.wrote = true; + append(JSON.stringify(key) + ":"); + stack.push({ kind: "value", value: entry }); + } + } + } + if (++steps % 512 === 0 && performance.now() - sliceStarted >= 8) { + await setImmediate(); + sliceStarted = performance.now(); } } hash.update(buffer); diff --git a/src/execution-world/artifacts.ts b/src/execution-world/artifacts.ts index d0a2e1d..b53e2d0 100644 --- a/src/execution-world/artifacts.ts +++ b/src/execution-world/artifacts.ts @@ -1,13 +1,13 @@ -import { createHash } from "node:crypto"; -import { canonicalJson } from "../capability-model/evolution.js"; import { + executionPlanDigestAsync, type ExecutionPlan, type PlanPackage, ExecutionPlanError, hexDigest, - hexDigestAsync, executionVersions, } from "./plan.js"; +import { createHash } from "node:crypto"; +import { canonicalJson } from "../capability-model/evolution.js"; export interface ArtifactFile { path: string; @@ -366,7 +366,7 @@ export async function checkExecutionWorld( ): Promise { if (canonicalJson(plan.definition.versions) !== canonicalJson(executionVersions)) fail("world", "Unsupported plan codec versions"); - if ((await hexDigestAsync(plan.definition)) !== plan.digest) fail("plan", "Plan digest mismatch"); + if ((await executionPlanDigestAsync(plan)) !== plan.digest) fail("plan", "Plan digest mismatch"); const manifests = inputs.map(parsePackageArtifacts); unique( manifests.map((m) => m.packageRevisionId), diff --git a/src/execution-world/candidate.ts b/src/execution-world/candidate.ts index 9a0e0b5..f9fd215 100644 --- a/src/execution-world/candidate.ts +++ b/src/execution-world/candidate.ts @@ -1,7 +1,7 @@ import { graphExecutionRepresentation } from "./graph-execution.js"; import { lstat } from "node:fs/promises"; import { join } from "node:path"; -import { hexDigest, hexDigestAsync, ExecutionPlanError, type ExecutionPlan } from "./plan.js"; +import { hexDigest, hexDigestAsync, sealExecutionPlan, ExecutionPlanError, type ExecutionPlan } from "./plan.js"; import { loadExecutionInstallation, createClientContractCatalog, @@ -72,7 +72,7 @@ export async function loadCheckedInstallationCandidate( const [plan, artifacts, source, entryRequest, kernel, queries] = values; if ( plan?.definition?.codec !== "camino-execution-plan-v1" || - (await hexDigestAsync(plan.definition)) !== plan.digest || + !(await sealExecutionPlan(plan)) || workspaceDigest !== (await hexDigestAsync(plan.definition.workspace)) ) throw new ExecutionPlanError( diff --git a/src/execution-world/client-contracts.ts b/src/execution-world/client-contracts.ts index 59f2e7b..11ebdd2 100644 --- a/src/execution-world/client-contracts.ts +++ b/src/execution-world/client-contracts.ts @@ -1,3 +1,4 @@ +import { executionPlanDigest, type ExecutionPlan, resolvePlannedOperation, ExecutionPlanError } from "./plan.js"; import type { InterfaceRevision, InterfaceOperation, @@ -6,7 +7,7 @@ import type { } from "../capability-model/types.js"; import { canonicalJson } from "../capability-model/evolution.js"; import { portableValueSchema, tsType } from "../bindings/portable.js"; -import { type ExecutionPlan, resolvePlannedOperation, hexDigest, ExecutionPlanError } from "./plan.js"; + import type { BindingSchema } from "../bindings/index.js"; import type { PackageBuildContract } from "./build-contract.js"; import { preparationArtifact, type ReactPreparationArtifact } from "../react-build/declarations.js"; @@ -55,7 +56,7 @@ export function publicOperationContract( } /** Resolve a public contract to internal dispatch. This does not authorize the receiver. */ export function linkInterfaceOperation(plan: ExecutionPlan, atom: string, contract: PublicOperationContract) { - if (hexDigest(plan.definition) !== plan.digest) + if (executionPlanDigest(plan) !== plan.digest) throw new ExecutionPlanError("INVALID_PLAN", atom, "Invalid execution plan digest"); const iface = plan.definition.workspace.interfaceImports.find((i) => i.revisionId === contract.interface); const operation = iface?.members.flatMap((m) => m.operations).find((o) => o.id === contract.operation); @@ -97,7 +98,7 @@ export function publicQueryContract(pkg: PackageRevision, queryId: string): Publ }; } export function linkPackageQuery(plan: ExecutionPlan, contract: PublicQueryContract) { - if (hexDigest(plan.definition) !== plan.digest) + if (executionPlanDigest(plan) !== plan.digest) throw new ExecutionPlanError("INVALID_PLAN", contract.query, "Invalid execution plan digest"); const pkg = plan.definition.workspace.packageImports.find((p) => p.revisionId === contract.package); if (!pkg || canonicalJson(publicQueryContract(pkg, contract.query)) !== canonicalJson(contract)) diff --git a/src/execution-world/execution-groups.ts b/src/execution-world/execution-groups.ts index ad4b8dd..116ade0 100644 --- a/src/execution-world/execution-groups.ts +++ b/src/execution-world/execution-groups.ts @@ -1,3 +1,4 @@ +import { executionPlanDigestAsync, type ExecutionPlan, hexDigest, hexDigestAsync, ExecutionPlanError } from "./plan.js"; import { portableReflectionValue } from "./reflection.js"; import type { BoundDependency, @@ -6,7 +7,6 @@ import type { PersistentAttachment, } from "../capability-model/types.js"; import { type ExecutionInstallation, type ExecutionGroupIdentity } from "./installation.js"; -import { type ExecutionPlan, hexDigest, hexDigestAsync, ExecutionPlanError } from "./plan.js"; export interface ExecutionEnvironment { /** Explicit host placement. Merely including an optional module does not select it. */ @@ -28,7 +28,7 @@ export async function compileExecutionGroups( installation: ExecutionInstallation, environments: Readonly>, ): Promise { - if (installation.plan !== plan.digest || (await hexDigestAsync(plan.definition)) !== plan.digest) + if (installation.plan !== plan.digest || (await executionPlanDigestAsync(plan)) !== plan.digest) throw new ExecutionPlanError("GROUP_PLAN_MISMATCH", plan.digest, "Expected one checked installation"); const workspace = plan.definition.workspace; const packages = new Map(workspace.packageImports.map((p) => [p.revisionId as string, p])); diff --git a/src/execution-world/graph-execution.ts b/src/execution-world/graph-execution.ts index 93fb031..ab2b009 100644 --- a/src/execution-world/graph-execution.ts +++ b/src/execution-world/graph-execution.ts @@ -1,4 +1,5 @@ -import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js"; +import { executionPlanDigest, ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js"; + import type { ExecutionInstallation } from "./installation.js"; /** Execution inputs, not build evidence. Nominal revision IDs remain dispatch @@ -10,7 +11,7 @@ export function graphExecutionRepresentation( kernel: unknown, queries: unknown, ) { - if (installation.plan !== plan.digest || hexDigest(plan.definition) !== plan.digest) + if (installation.plan !== plan.digest || executionPlanDigest(plan) !== plan.digest) throw new ExecutionPlanError("GRAPH_PLAN_MISMATCH", "graph", "Expected one checked installation"); if ( !kernel || diff --git a/src/execution-world/installation.ts b/src/execution-world/installation.ts index b5eb379..92d9d22 100644 --- a/src/execution-world/installation.ts +++ b/src/execution-world/installation.ts @@ -1,9 +1,10 @@ +import { executionPlanDigestAsync, ExecutionPlanError, type ExecutionPlan } from "./plan.js"; import { compileInspectionTemplates, type InspectionTemplate, type InspectionKernel } from "./inspection.js"; import path from "node:path"; import { loadCheckedArtifactWorld } from "./receipts.js"; import { contentArtifactUrl, loadArtifactWorld, type LoadedArtifactWorld } from "./files.js"; import { selectArtifactImplementation, type ArtifactImplementation, type ComponentArtifact } from "./artifacts.js"; -import { ExecutionPlanError, hexDigestAsync, type ExecutionPlan } from "./plan.js"; + import { publicOperationContract, publicQueryContract, @@ -47,7 +48,7 @@ export async function clientContractCatalog( requestedPackages: readonly string[], checkedKernel?: InspectionKernel, ): Promise { - if (installation.plan !== plan.digest || (await hexDigestAsync(plan.definition)) !== plan.digest) + if (installation.plan !== plan.digest || (await executionPlanDigestAsync(plan)) !== plan.digest) throw new ExecutionPlanError( "CATALOG_PLAN_MISMATCH", plan.digest, diff --git a/src/execution-world/kernel.ts b/src/execution-world/kernel.ts index 47d9ff5..4a23a0c 100644 --- a/src/execution-world/kernel.ts +++ b/src/execution-world/kernel.ts @@ -1,3 +1,4 @@ +import { executionPlanDigest, type ExecutionPlan, ExecutionPlanError, hexDigest } from "./plan.js"; import { portableReflectionType, portableReflectionValue } from "./reflection.js"; import type { ValueType, @@ -7,7 +8,7 @@ import type { BoundDependency, PackageExport, } from "../capability-model/types.js"; -import { type ExecutionPlan, ExecutionPlanError, hexDigest } from "./plan.js"; + import { portableValueSchema } from "../bindings/portable.js"; import { subscriptionContract } from "../capability-model/journals.js"; type PreparedStep = string | null | { case: string; id: string }; @@ -25,7 +26,7 @@ function preparedInputPaths(type: ValueType, path: PreparedStep[] = []): Prepare } /** Small data-only interpreter input; no compiler, parser, Node module or SQL dependency reaches the browser. */ export function compileKernelProgram(plan: ExecutionPlan): unknown { - if (hexDigest(plan.definition) !== plan.digest) + if (executionPlanDigest(plan) !== plan.digest) throw new ExecutionPlanError("INVALID_PLAN", "kernel", "Plan digest mismatch"); const workspace = plan.definition.workspace; if (workspace.sharedAttachments.some((attachment) => attachment.kind !== "edge")) diff --git a/src/execution-world/placement.ts b/src/execution-world/placement.ts index 569b96a..e00e77f 100644 --- a/src/execution-world/placement.ts +++ b/src/execution-world/placement.ts @@ -1,6 +1,7 @@ +import { executionPlanDigest, ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js"; import { parse } from "@babel/parser"; import { builtinModules } from "node:module"; -import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js"; + import type { ExecutionInstallation } from "./installation.js"; import type { ArtifactImplementation, ArtifactReader } from "./artifacts.js"; @@ -212,7 +213,7 @@ export async function reportExecutionPlacement( installation: ExecutionInstallation, read: ArtifactReader, ) { - if (installation.plan !== plan.digest || hexDigest(plan.definition) !== plan.digest) + if (installation.plan !== plan.digest || executionPlanDigest(plan) !== plan.digest) throw new ExecutionPlanError( "PLACEMENT_PLAN_MISMATCH", plan.digest, diff --git a/src/execution-world/plan.ts b/src/execution-world/plan.ts index bc5cd6d..e941965 100644 --- a/src/execution-world/plan.ts +++ b/src/execution-world/plan.ts @@ -17,6 +17,7 @@ import { linkQueries, type LinkedQuery } from "../query/link.js"; import { type BindingSchema } from "../bindings/index.js"; import { unsupportedQueryOperator } from "./query-operators.js"; import { packageBuildContexts } from "./build-contract.js"; +import { setImmediate } from "node:timers/promises"; export class ExecutionPlanError extends Error { constructor( @@ -31,6 +32,54 @@ export class ExecutionPlanError extends Error { export const hexDigest = (value: unknown): string => contentDigest(value).slice("sha256:".length); export const hexDigestAsync = async (value: unknown): Promise => (await contentDigestAsync(value)).slice("sha256:".length); +// Checked runtime metadata is owned JSON, not live Graph data. Reuse its proof +// only after every reachable record is immutable. Weak keys retain neither old +// Candidates nor an application cache; unsealed compiler inputs are rehashed. +const sealedPlanDigests = new WeakMap(); +export const executionPlanDigest = (plan: ExecutionPlan): string => + sealedPlanDigests.get(plan) ?? hexDigest(plan.definition); +export const executionPlanDigestAsync = async (plan: ExecutionPlan): Promise => + sealedPlanDigests.get(plan) ?? (await hexDigestAsync(plan.definition)); +/** Transfers owned parsed JSON into immutable runtime metadata. Freeze before + * yielding to hashing so concurrent mutation cannot invalidate the proof. */ +export async function sealExecutionPlan(plan: ExecutionPlan): Promise { + if (sealedPlanDigests.has(plan)) return true; + if (!Object.hasOwn(plan, "definition") || !Object.hasOwn(plan, "digest")) return false; + const pending: object[] = [plan], + seen = new WeakSet(); + let sliceStarted = performance.now(), + count = 0; + while (pending.length) { + const value = pending.pop()!; + if (seen.has(value)) continue; + if ( + !Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null + ) + throw new ExecutionPlanError("INVALID_PLAN_JSON", "plan", "Expected owned JSON records"); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key)!; + if (typeof key !== "string" || !Object.hasOwn(descriptor, "value")) + throw new ExecutionPlanError( + "INVALID_PLAN_JSON", + "plan", + "Runtime metadata cannot contain accessors or symbols", + ); + if (descriptor.value !== null && typeof descriptor.value === "object") pending.push(descriptor.value); + } + Object.freeze(value); + seen.add(value); + if (++count % 1024 === 0 && performance.now() - sliceStarted >= 8) { + await setImmediate(); + sliceStarted = performance.now(); + } + } + const digest = await hexDigestAsync(plan.definition); + if (digest !== plan.digest) return false; + sealedPlanDigests.set(plan, digest); + return true; +} const sorted = (values: readonly T[], key: (value: T) => string): T[] => [...values].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0)); export const publicOperationKey = (atom: string, iface: string, operation: string): string => diff --git a/src/execution-world/projections.ts b/src/execution-world/projections.ts index 2eb24af..d00913a 100644 --- a/src/execution-world/projections.ts +++ b/src/execution-world/projections.ts @@ -1,4 +1,5 @@ -import { ExecutionPlanError, hexDigest, type ExecutionPlan, type PlannedQuery } from "./plan.js"; +import { executionPlanDigest, ExecutionPlanError, type ExecutionPlan, type PlannedQuery } from "./plan.js"; + /** Contracts promised by a direct result. Interface dependencies are explicit; accidental * overlap among today's implementations does not silently widen an exported interface contract. */ export function resultInterfaces(plan: ExecutionPlan, operationKey: string): string[] { @@ -23,7 +24,7 @@ export function resultInterfaces(plan: ExecutionPlan, operationKey: string): str } /** Compile-time observeResult compatibility. Observation does not grant read or mutation authority. */ export function checkResultProjection(plan: ExecutionPlan, operationKey: string, queryId: string): PlannedQuery { - if (hexDigest(plan.definition) !== plan.digest) + if (executionPlanDigest(plan) !== plan.digest) throw new ExecutionPlanError("INVALID_PLAN", "projection", "Plan digest mismatch"); const query = plan.definition.queries.find((q) => q.id === queryId); if (!query) throw new ExecutionPlanError("UNKNOWN_PROJECTION", queryId, "No installed query"); diff --git a/src/execution-world/queries.ts b/src/execution-world/queries.ts index 1513014..a924cdc 100644 --- a/src/execution-world/queries.ts +++ b/src/execution-world/queries.ts @@ -1,7 +1,8 @@ +import { executionPlanDigest, ExecutionPlanError, type ExecutionPlan } from "./plan.js"; import { compileKernelProgram } from "./kernel.js"; import { compileInspectionTemplates } from "./inspection.js"; import { portableValueSchema } from "../bindings/portable.js"; -import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js"; + import type { ValueType } from "../capability-model/types.js"; import { compileQueryComposition } from "../query/composition.js"; import { queryTreeBlueprint } from "./query-trees.js"; @@ -24,7 +25,7 @@ const lowerTypes = (value: unknown): unknown => /** Checked query IR and installed getter identities, without GraphQL schemas/parsers or SQL adapters. * Keep authored selection names only for output paths; field access uses resolved interface/member IDs. */ export function compileQueryProgram(plan: ExecutionPlan): unknown { - if (hexDigest(plan.definition) !== plan.digest) + if (executionPlanDigest(plan) !== plan.digest) throw new ExecutionPlanError("INVALID_PLAN", "queries", "Plan digest mismatch"); const allQueries = new Map(plan.definition.queries.map(({ id, query }) => [id, query.checked])); const compositions = plan.definition.workspace.packageImports.flatMap((pkg) => diff --git a/src/execution-world/query-document.ts b/src/execution-world/query-document.ts index a897ab8..570a465 100644 --- a/src/execution-world/query-document.ts +++ b/src/execution-world/query-document.ts @@ -1,8 +1,9 @@ +import { executionPlanDigest, hexDigest, type ExecutionPlan, type PlannedQuery } from "./plan.js"; import { compileQuery } from "../query/compile.js"; import { linkQueries } from "../query/link.js"; import { capabilityId } from "../capability-model/types.js"; import { portableValueSchema } from "../bindings/portable.js"; -import { hexDigest, type ExecutionPlan, type PlannedQuery } from "./plan.js"; + import { compileKernelProgram } from "./kernel.js"; import { queryProgramEntry } from "./queries.js"; import { unsupportedQueryOperator } from "./query-operators.js"; @@ -17,7 +18,7 @@ export interface QueryDocument { /** Runtime query documents use the same language, nominal contracts, linking * and interpreter as packaged queries. They introduce no resources or code. */ export async function compilePublicQuery(plan: ExecutionPlan, request: QueryDocument) { - if (hexDigest(plan.definition) !== plan.digest) throw Error("INVALID_PLAN"); + if (executionPlanDigest(plan) !== plan.digest) throw Error("INVALID_PLAN"); if ( typeof request.document !== "string" || Buffer.byteLength(request.document) > 32768 || diff --git a/test/evolution.test.ts b/test/evolution.test.ts index 8a40286..754fe45 100644 --- a/test/evolution.test.ts +++ b/test/evolution.test.ts @@ -55,10 +55,22 @@ test("hashing is canonical and rejects values outside the persisted JSON contrac test("cooperative hashing preserves canonical identities and services the event loop", async () => { const sparse = [1, 2, 3]; delete sparse[1]; - const values = [null, false, -0, "中\\n\ud800", sparse, { z: 1, a: { omitted: undefined, b: [false, 1e100] } }]; + const values: unknown[] = [ + null, + false, + -0, + "中\\n\ud800", + sparse, + { z: 1, a: { omitted: undefined, b: [false, 1e100] } }, + ]; + const shared = { text: "中😀".repeat(22000) }; + values.push({ first: shared, second: shared }); for (const value of values) assert.equal(await contentDigestAsync(value), contentDigest(value)); for (const value of [undefined, NaN, Infinity, new Date(), [undefined]]) await assert.rejects(contentDigestAsync(value)); + const cyclic: { next?: unknown } = {}; + cyclic.next = cyclic; + await assert.rejects(contentDigestAsync(cyclic), /cyclic JSON/); const large = Array.from({ length: 50000 }, (_, index) => ({ z: "definition".repeat(8), a: index })); const expected = contentDigest(large); let serviced = false; diff --git a/test/execution-world.test.ts b/test/execution-world.test.ts index 471a62c..ab2047b 100644 --- a/test/execution-world.test.ts +++ b/test/execution-world.test.ts @@ -9,6 +9,9 @@ import { implementationDigest, clientContractCatalog, hexDigest, + executionPlanDigest, + executionPlanDigestAsync, + sealExecutionPlan, parsePackageArtifacts, artifactPath, type PackageArtifacts, @@ -21,6 +24,54 @@ import { compileKernelProgram } from "../src/execution-world/kernel.js"; import { loadCompositeWorkspace } from "./composite-fixture.js"; import { standardNativeBackends } from "../src/execution-world/native-backends.js"; +test("sealed runtime plans reuse verified integrity while mutable copies are rechecked", async () => { + const workspace = await loadReaderWorkspace(); + const compiled = compileExecutionPlan(workspace); + const plan: ExecutionPlan = JSON.parse(JSON.stringify(compiled)); + assert.equal(await sealExecutionPlan(plan), true); + assert.equal(executionPlanDigest(plan), compiled.digest); + assert.equal(await executionPlanDigestAsync(plan), compiled.digest); + assert.throws(() => { + plan.definition.workspace.sourceRootCommit = "f".repeat(40); + }, TypeError); + assert.throws(() => { + plan.digest = "0".repeat(64); + }, TypeError); + + const stringify = JSON.stringify; + JSON.stringify = () => { + throw Error("Repeated serialization of sealed metadata"); + }; + try { + assert.equal(executionPlanDigest(plan), compiled.digest); + assert.equal(await executionPlanDigestAsync(plan), compiled.digest); + } finally { + JSON.stringify = stringify; + } + + const changed: ExecutionPlan = structuredClone(plan); + changed.definition.workspace.sourceRootCommit = "f".repeat(40); + Object.freeze(changed); + assert.notEqual(executionPlanDigest(changed), plan.digest); + assert.notEqual(await executionPlanDigestAsync(changed), plan.digest); + assert.equal(await sealExecutionPlan(changed), false, "shallow freezing is not an integrity proof"); + + const resigned: ExecutionPlan = structuredClone(plan); + resigned.definition.workspace.sourceRootCommit = "e".repeat(40); + resigned.digest = executionPlanDigest(resigned); + assert.equal(await sealExecutionPlan(resigned), true); + assert.notEqual(executionPlanDigest(resigned), plan.digest); + workspace.sourceRootCommit = "d".repeat(40); + assert.equal(workspace.sourceRootCommit, "d".repeat(40), "sealing owned JSON leaves compiler inputs editable"); +}); + +test("runtime plan sealing rejects accessor-backed metadata", async () => { + const plan: ExecutionPlan = compileExecutionPlan(await loadReaderWorkspace()); + assert.equal(await sealExecutionPlan(Object.create(plan)), false, "inherited integrity fields cannot be sealed"); + Object.defineProperty(plan.definition, "codec", { get: () => "camino-execution-plan-v1", enumerable: true }); + await assert.rejects(sealExecutionPlan(plan), /INVALID_PLAN_JSON/); +}); + test("optional targets close over calls into another package", async () => { const workspace = await loadCompositeWorkspace(); const before = compileExecutionPlan(workspace).definition.operations.find(