From a48fccc328d11924f54a16e4ad1922fa736a76be Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Mon, 12 Oct 2026 16:09:39 -0700 Subject: [PATCH] Keep cold Candidate verification cooperative with controller renewals --- src/capability-model/evolution.ts | 47 +++++++++++++++++++++++++++++++ src/execution-world/artifacts.ts | 11 ++++++-- src/execution-world/candidate.ts | 20 +++++++------ src/execution-world/plan.ts | 4 ++- test/evolution.test.ts | 22 +++++++++++++++ 5 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/capability-model/evolution.ts b/src/capability-model/evolution.ts index 96d18b1..32e0cf0 100644 --- a/src/capability-model/evolution.ts +++ b/src/capability-model/evolution.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { setImmediate } from "node:timers/promises"; import type { Binding, Conformance, DependencyBinding, PersistentAttachment, WorkspaceRevision } from "./types.js"; import { validateWorkspaceRevision } from "./validation.js"; @@ -21,6 +22,52 @@ export const canonicalJson = (value: unknown): string => { }; export const contentDigest = (value: unknown) => `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +/** Same canonical bytes as contentDigest, without monopolizing a live host's + * 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"); + let buffer = "", + sliceStarted = performance.now(); + for (const part of parts(value)) { + buffer += part; + if (buffer.length >= 65536) { + hash.update(buffer); + buffer = ""; + if (performance.now() - sliceStarted >= 8) { + await setImmediate(); + sliceStarted = performance.now(); + } + } + } + hash.update(buffer); + return `sha256:${hash.digest("hex")}`; +} const semantic = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(semantic); if (value && typeof value === "object") diff --git a/src/execution-world/artifacts.ts b/src/execution-world/artifacts.ts index 85f05f1..d0a2e1d 100644 --- a/src/execution-world/artifacts.ts +++ b/src/execution-world/artifacts.ts @@ -1,6 +1,13 @@ import { createHash } from "node:crypto"; import { canonicalJson } from "../capability-model/evolution.js"; -import { type ExecutionPlan, type PlanPackage, ExecutionPlanError, hexDigest, executionVersions } from "./plan.js"; +import { + type ExecutionPlan, + type PlanPackage, + ExecutionPlanError, + hexDigest, + hexDigestAsync, + executionVersions, +} from "./plan.js"; export interface ArtifactFile { path: string; @@ -359,7 +366,7 @@ export async function checkExecutionWorld( ): Promise { if (canonicalJson(plan.definition.versions) !== canonicalJson(executionVersions)) fail("world", "Unsupported plan codec versions"); - if (hexDigest(plan.definition) !== plan.digest) fail("plan", "Plan digest mismatch"); + if ((await hexDigestAsync(plan.definition)) !== 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 c7c8b05..9a0e0b5 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, ExecutionPlanError, type ExecutionPlan } from "./plan.js"; +import { hexDigest, hexDigestAsync, ExecutionPlanError, type ExecutionPlan } from "./plan.js"; import { loadExecutionInstallation, createClientContractCatalog, @@ -50,7 +50,7 @@ export async function loadCheckedInstallationCandidate( const input = await readJson(root, "candidate.json", scope); try { const { executionContracts: _legacyContracts, ...workspace } = input.value; - return hexDigest(workspace); + return await hexDigestAsync(workspace); } finally { input.releaseTree(); } @@ -72,8 +72,8 @@ export async function loadCheckedInstallationCandidate( const [plan, artifacts, source, entryRequest, kernel, queries] = values; if ( plan?.definition?.codec !== "camino-execution-plan-v1" || - hexDigest(plan.definition) !== plan.digest || - workspaceDigest !== hexDigest(plan.definition.workspace) + (await hexDigestAsync(plan.definition)) !== plan.digest || + workspaceDigest !== (await hexDigestAsync(plan.definition.workspace)) ) throw new ExecutionPlanError( "CANDIDATE_PLAN_MISMATCH", @@ -117,7 +117,11 @@ export async function loadCheckedInstallationCandidate( ["kernel", kernel, "camino-kernel-program-v1"], ["queries", queries, "camino-query-program-v1"], ] as const) - if (program?.codec !== codec || program.plan !== kernel.plan || hexDigest(program) !== artifacts.programs?.[name]) + if ( + program?.codec !== codec || + program.plan !== kernel.plan || + (await hexDigestAsync(program)) !== artifacts.programs?.[name] + ) throw new ExecutionPlanError( "CANDIDATE_PROGRAM_MISMATCH", name, @@ -129,8 +133,8 @@ export async function loadCheckedInstallationCandidate( await (async () => { const input = await readJson(root, "graph-execution.json", scope); try { - const digest = hexDigest(input.value); - if (digest !== hexDigest(expectedGraph) || artifacts.execution !== digest) + const digest = await hexDigestAsync(input.value); + if (digest !== (await hexDigestAsync(expectedGraph)) || artifacts.execution !== digest) throw new ExecutionPlanError( "CANDIDATE_EXECUTION_MISMATCH", root, @@ -164,7 +168,7 @@ export async function loadCheckedInstallationCandidate( return { reference, workspace: plan.definition.workspace.workspaceId, - world: hexDigest(expectedGraph), + world: await hexDigestAsync(expectedGraph), plan: kernel.plan as string, graphExecution: expectedGraph, provenance, diff --git a/src/execution-world/plan.ts b/src/execution-world/plan.ts index da65c4c..bc5cd6d 100644 --- a/src/execution-world/plan.ts +++ b/src/execution-world/plan.ts @@ -11,7 +11,7 @@ import type { OperationId, } from "../capability-model/types.js"; import { validateWorkspaceRevision } from "../capability-model/validation.js"; -import { canonicalJson, contentDigest } from "../capability-model/evolution.js"; +import { canonicalJson, contentDigest, contentDigestAsync } from "../capability-model/evolution.js"; import { resolveForwardedBinding } from "../capability-model/forwarding.js"; import { linkQueries, type LinkedQuery } from "../query/link.js"; import { type BindingSchema } from "../bindings/index.js"; @@ -29,6 +29,8 @@ 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); 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/test/evolution.test.ts b/test/evolution.test.ts index 97ccb97..8a40286 100644 --- a/test/evolution.test.ts +++ b/test/evolution.test.ts @@ -3,6 +3,7 @@ import { test } from "node:test"; import { capabilityId as id, valueType, validateWorkspaceRevision } from "../src/capability-model/index.js"; import { contentDigest, + contentDigestAsync, planEvolution, reviewSemanticMajors, runtimeContracts, @@ -51,6 +52,27 @@ test("hashing is canonical and rejects values outside the persisted JSON contrac assert.throws(() => contentDigest(new Date())); }); +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] } }]; + 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 large = Array.from({ length: 50000 }, (_, index) => ({ z: "definition".repeat(8), a: index })); + const expected = contentDigest(large); + let serviced = false; + const timer = setTimeout(() => { + serviced = true; + }, 0); + try { + assert.equal(await contentDigestAsync(large), expected); + assert.equal(serviced, true, "large verification must let host renewals and RPCs run"); + } finally { + clearTimeout(timer); + } +}); + test("a workspace root change and display names do not restart package runtimes", () => { const before = makeValidCapabilityWorkspace(); const after = structuredClone(before);