diff --git a/.quixos-subtree-source.json b/.quixos-subtree-source.json index fb594f9..772f023 100644 --- a/.quixos-subtree-source.json +++ b/.quixos-subtree-source.json @@ -1,7 +1,7 @@ { "version": 1, - "sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos", - "sourceCommit": "8014b4ba717313e1b494c59a8e2781df58bf9a85", + "sourceRepo": "https://gitea.internal.quixos.org/quixos/quixos.git", + "sourceCommit": "e4393946ba63cfef75580f6aa6ba840035e7d43c", "sourcePath": "quixos-protocol", "exportName": "quixos-protocol", "mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" diff --git a/nix/checked-candidate.nix b/nix/checked-candidate.nix index 4ccb7a6..5af96c9 100644 --- a/nix/checked-candidate.nix +++ b/nix/checked-candidate.nix @@ -7,6 +7,7 @@ generator, contractOnly ? false, workspaceId ? null, + platformSourceJSON ? null, system ? builtins.currentSystem, }: let @@ -71,9 +72,21 @@ let else { }; execution = (configuration.backend or "typescript") == "execution"; - runtime = builtins.getFlake ( - "git+${root.lock.quixos.repository}?rev=${root.lock.quixos.commit}&ref=refs/tags/quixos-reachability/${root.lock.quixos.commit}" - ); + platformSource = if platformSourceJSON == null then null else builtins.fromJSON platformSourceJSON; + runtime = + if platformSource == null then + builtins.getFlake ( + "git+${root.lock.quixos.repository}?rev=${root.lock.quixos.commit}&ref=refs/tags/quixos-reachability/${root.lock.quixos.commit}" + ) + else + assert platformSource.repository == root.lock.quixos.repository; + assert platformSource.commit == root.lock.quixos.commit; + # getFlake accepts a context-free flake reference. storePath first checks + # presence; the resulting flake retains its immutable source as an input. + # Dropping string context here does not permit an authored mutable path. + builtins.getFlake ( + builtins.unsafeDiscardStringContext "path:${builtins.storePath platformSource.directory}" + ); runtimePackages = runtime.packages.${system}; resourceClosure = includeBuild: node: diff --git a/src/capability-language/checked-build.ts b/src/capability-language/checked-build.ts index e7d21a0..922079d 100644 --- a/src/capability-language/checked-build.ts +++ b/src/capability-language/checked-build.ts @@ -113,6 +113,7 @@ export async function buildImmutableCandidate( contractOnly = false, observe?: (phase: "nixEvaluationMs" | "nixBuildMs", ms: number) => void, workspaceId?: string, + platformSource?: { repository: string; commit: string; directory: string }, ): Promise { if (workspaceId !== undefined && (kind !== "workspace" || !/^[a-zA-Z0-9][a-zA-Z0-9:._-]{0,255}$/.test(workspaceId))) throw new Error("Instance binding requires a valid Workspace identity and a Workspace root"); @@ -121,6 +122,13 @@ export async function buildImmutableCandidate( const url = new URL(source.repository); if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Candidate origin must be credential-free HTTPS"); + if ( + platformSource && + (Object.keys(platformSource).sort().join(",") !== "commit,directory,repository" || + !/^[a-f0-9]{40}$/.test(platformSource.commit) || + !/^\/nix\/store\/[a-z0-9]{32}-[^/\s]+$/.test(platformSource.directory)) + ) + throw Error("Invalid immutable platform source input"); const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Use the installed Quixos checker"); @@ -204,6 +212,7 @@ export async function buildImmutableCandidate( "contractOnly", contractOnly ? "true" : "false", ...(workspaceId === undefined ? [] : ["--argstr", "workspaceId", workspaceId]), + ...(platformSource ? ["--argstr", "platformSourceJSON", JSON.stringify(platformSource)] : []), "--add-root", gcRoot, "--indirect", diff --git a/src/capability-language/development-sources.ts b/src/capability-language/development-sources.ts index 97e8d26..e6d540a 100644 --- a/src/capability-language/development-sources.ts +++ b/src/capability-language/development-sources.ts @@ -16,8 +16,11 @@ export async function prepareDevelopmentSources(options: { directory: string; cacheDirectory: string; locations?: Record; + workspaceId?: string; }) { const source = workspaceSourcePin(options.source); + if (options.workspaceId !== undefined && !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/.test(options.workspaceId)) + throw Error("Development source binding requires a Workspace UUID"); const destination = path.resolve(options.directory); const cache = path.resolve(options.cacheDirectory); if (cache === destination || cache.startsWith(destination + path.sep)) @@ -43,7 +46,8 @@ export async function prepareDevelopmentSources(options: { const parent = path.dirname(destination); await mkdir(parent, { recursive: true }); const staging = await mkdtemp(path.join(parent, ".development-sources-")); - const workspaceId = randomUUID(); + // This local association does not create or grant access to a hosted Graph. + const workspaceId = options.workspaceId ?? randomUUID(); try { const resources = []; for (const entry of closure.resources) { diff --git a/src/capability-language/file-lock.ts b/src/capability-language/file-lock.ts index a1419ba..2a27ece 100644 --- a/src/capability-language/file-lock.ts +++ b/src/capability-language/file-lock.ts @@ -1,14 +1,19 @@ import { spawn } from "node:child_process"; +export class FileLockBusyError extends Error {} + /** Kernel-owned lock: a crashed coordinator cannot leave a stale ownership file. * The persistent file is just an inode; EOF releases the helper's lock. */ -export async function withFileLock(filename: string, work: () => Promise): Promise { +export async function withFileLock( + filename: string, + work: () => Promise, + { nonblocking = false }: { nonblocking?: boolean } = {}, +): Promise { const child = spawn( "flock", [ "--exclusive", - "--timeout", - "120", + ...(nonblocking ? ["--nonblock"] : ["--timeout", "120"]), "--conflict-exit-code", "75", filename, @@ -32,15 +37,17 @@ export async function withFileLock(filename: string, work: () => Promise): await new Promise((resolve, reject) => { let output = ""; child.once("error", reject); - child.once("exit", (code) => + child.once("exit", (code) => { reject( - new Error( - code === 75 - ? "Timed out after 120 seconds waiting for another authoring command; inspect that command before retrying" - : `Cannot acquire authoring lock: ${diagnostics}`, - ), - ), - ); + code === 75 + ? new FileLockBusyError( + nonblocking + ? "Another process owns this lock" + : "Timed out after 120 seconds waiting for another authoring command; inspect that command before retrying", + ) + : new Error(`Cannot acquire authoring lock: ${diagnostics}`), + ); + }); child.stdout.on("data", (chunk) => { output += chunk; if (output.includes("locked\n")) resolve(); diff --git a/src/capability-language/source-check-driver.ts b/src/capability-language/source-check-driver.ts index 4a4169a..e627287 100644 --- a/src/capability-language/source-check-driver.ts +++ b/src/capability-language/source-check-driver.ts @@ -6,5 +6,13 @@ const [repository, commit, log, workspaceId] = process.argv.slice(2); if (!repository || !commit || !log || !workspaceId || process.argv.length !== 6) throw Error("source-check-driver REPOSITORY COMMIT LOG WORKSPACE_ID"); process.stdout.write( - (await buildImmutableCandidate({ repository, commit }, "workspace", log, false, undefined, workspaceId)) + "\n", + (await buildImmutableCandidate( + { repository, commit }, + "workspace", + log, + false, + undefined, + workspaceId, + process.env.QUIXOS_CHECK_PLATFORM_SOURCE ? JSON.parse(process.env.QUIXOS_CHECK_PLATFORM_SOURCE) : undefined, + )) + "\n", ); diff --git a/src/capability-language/tool-cli.ts b/src/capability-language/tool-cli.ts index 41a382d..7de8d1b 100644 --- a/src/capability-language/tool-cli.ts +++ b/src/capability-language/tool-cli.ts @@ -73,7 +73,7 @@ const main = async () => { "--dir", ...(args[1] !== "restore" ? ["--locations"] : []), ...(args[1] === "checkout" ? ["--repository-prefix"] : []), - ...(args[1] === "develop" ? ["--cache"] : []), + ...(args[1] === "develop" ? ["--cache", "--workspace-id"] : []), ...(args[1] === "backup" ? ["--output"] : []), ].includes(args[i]) || !args[i + 1] || @@ -100,7 +100,11 @@ const main = async () => { if (args[1] === "develop") { if (!flags.get("--cache")) throw Error("source develop requires --cache DIRECTORY"); const { prepareDevelopmentSources } = await import("./development-sources.js"); - result = await prepareDevelopmentSources({ ...options, cacheDirectory: path.resolve(flags.get("--cache")!) }); + result = await prepareDevelopmentSources({ + ...options, + cacheDirectory: path.resolve(flags.get("--cache")!), + ...(flags.has("--workspace-id") ? { workspaceId: flags.get("--workspace-id")! } : {}), + }); } else if (args[1] === "backup") { if (!flags.get("--output")) throw Error("source backup requires --output DIRECTORY"); const { backupWorkspaceSources } = await import("./source-backup.js"); diff --git a/src/capability-model/evolution.ts b/src/capability-model/evolution.ts index 96d18b1..f6ec15a 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,89 @@ 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 { + // 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(), + steps = 0; + const append = (part: string) => { + buffer += part; + if (buffer.length >= 65536) { + hash.update(buffer); + buffer = ""; + } + }; + 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); + 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..b53e2d0 100644 --- a/src/execution-world/artifacts.ts +++ b/src/execution-world/artifacts.ts @@ -1,6 +1,13 @@ +import { + executionPlanDigestAsync, + type ExecutionPlan, + type PlanPackage, + ExecutionPlanError, + hexDigest, + executionVersions, +} from "./plan.js"; import { createHash } from "node:crypto"; import { canonicalJson } from "../capability-model/evolution.js"; -import { type ExecutionPlan, type PlanPackage, ExecutionPlanError, hexDigest, 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 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 c7c8b05..924fd73 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, sealExecutionPlan, 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 sealExecutionPlan(plan)) || + 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, @@ -126,16 +130,17 @@ export async function loadCheckedInstallationCandidate( const expectedGraph = graphExecutionRepresentation(plan, installation, kernel, queries); // The stored graph embeds the same kernel/query programs. Validate it in a // short-lived scope and retain the representation that shares those trees. - await (async () => { + const graphDigest = 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, "Stored graph execution differs from checked inputs", ); + return digest; } finally { input.releaseTree(); } @@ -164,7 +169,8 @@ export async function loadCheckedInstallationCandidate( return { reference, workspace: plan.definition.workspace.workspaceId, - world: hexDigest(expectedGraph), + // The checked representation was already compared with this digest. + world: graphDigest, plan: kernel.plan as string, graphExecution: expectedGraph, provenance, 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 2b07126..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, ExecutionPlanError } from "./plan.js"; export interface ExecutionEnvironment { /** Explicit host placement. Merely including an optional module does not select it. */ @@ -23,12 +23,12 @@ export interface InstalledExecutionGroup extends ExecutionGroupIdentity { } /** Conservative outbound closure. Runtime graph values, reverse callers, source * provenance and the workspace epoch are deliberately absent from this identity. */ -export function compileExecutionGroups( +export async function compileExecutionGroups( plan: ExecutionPlan, installation: ExecutionInstallation, environments: Readonly>, -): InstalledExecutionGroup[] { - if (installation.plan !== plan.digest || hexDigest(plan.definition) !== plan.digest) +): Promise { + 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])); @@ -82,178 +82,175 @@ export function compileExecutionGroups( }; const bindingContract = (value: DependencyBinding) => value.kind === "query" ? { ...value, packageRevisionId: logical(value.packageRevisionId) } : value; - return installation.packages - .filter((p) => !!implementations.get(p.revision)) - .map((root) => { - const consumed = new Map(), - visited = new Set(), - visitedQueries = new Set(), - consumedPackages = new Set(); - const remember = (key: string, value: unknown) => consumed.set(key, value); - const storage = (id: string) => { - const value = attachments.get(id); - if (!value) throw new ExecutionPlanError("GROUP_STORAGE_MISSING", id, "Uninstalled consumed storage"); - remember(`storage:${id}`, attachmentContract(value)); - }; - // Deletion may visit related objects and their owned state. Conservatively - // include the connected schema component, including possible future edges. - const lifecycle = (atom: string) => { - const visited = new Set(), - pending = [atom]; - const atoms = ( - constraint: Extract["endpoints"][number]["constraint"], - ) => - constraint.kind === "atom" - ? [constraint.atomId as string] - : workspace.conformances - .filter((c) => c.interfaceRevisionId === constraint.interfaceRevisionId) - .map((c) => c.atomId as string); - while (pending.length) { - const current = pending.pop()!; - if (visited.has(current)) continue; - visited.add(current); - for (const owner of workspace.conformances.filter((c) => c.atomId === current)) - for (const attachment of owner.privateAttachments) storage(attachment.id); - for (const attachment of attachments.values()) - if (attachment.kind === "edge" && attachment.endpoints.some((e) => atoms(e.constraint).includes(current))) { - storage(attachment.id); - pending.push(...attachment.endpoints.flatMap((e) => atoms(e.constraint))); - } - } - }; - const targets = (binding: DependencyBinding, atom: string): string[] => { - if (binding.kind === "interface" && binding.receiver === "input") - return workspace.conformances - .filter((c) => c.interfaceRevisionId === binding.interfaceRevisionId) - .map((c) => c.atomId); - if (!("via" in binding) || !binding.via) return [atom]; - storage(binding.via.edgeTypeId); - const edge = attachments.get(binding.via.edgeTypeId); - if (!edge || edge.kind !== "edge") - throw new ExecutionPlanError("GROUP_EDGE_MISSING", atom, "Missing traversal edge"); - const endpoint = edge.endpoints.find((e) => e.projectionId !== binding.via!.projectionId)!; - const constraint = endpoint.constraint; - return constraint.kind === "atom" - ? [constraint.atomId] + const groups: InstalledExecutionGroup[] = []; + for (const root of installation.packages.filter((p) => !!implementations.get(p.revision))) { + const consumed = new Map(), + visited = new Set(), + visitedQueries = new Set(), + consumedPackages = new Set(); + const remember = (key: string, value: unknown) => consumed.set(key, value); + const storage = (id: string) => { + const value = attachments.get(id); + if (!value) throw new ExecutionPlanError("GROUP_STORAGE_MISSING", id, "Uninstalled consumed storage"); + remember(`storage:${id}`, attachmentContract(value)); + }; + // Deletion may visit related objects and their owned state. Conservatively + // include the connected schema component, including possible future edges. + const lifecycle = (atom: string) => { + const visited = new Set(), + pending = [atom]; + const atoms = (constraint: Extract["endpoints"][number]["constraint"]) => + constraint.kind === "atom" + ? [constraint.atomId as string] : workspace.conformances .filter((c) => c.interfaceRevisionId === constraint.interfaceRevisionId) - .map((c) => c.atomId); - }; - const query = (revision: string, id: string) => { - const key = JSON.stringify([revision, id]); - if (visitedQueries.has(key)) return; - visitedQueries.add(key); - const value = plan.definition.queries.find((q) => q.id === key); - if (!value) throw new ExecutionPlanError("GROUP_QUERY_MISSING", key, "Missing consumed query"); - remember(`query:${JSON.stringify([logical(revision), id])}`, value.query.checked.definitionDigest); - for (const operation of value.operations) visit(operation); - }; - const dependencies = (entry: PackageExport, bindings: readonly BoundDependency[], atom: string) => { - for (const bound of bindings) { - const target = bound.binding, - port = entry.dependencyPorts.find((p) => p.id === bound.portId)!; - if ("via" in target && target.via) storage(target.via.edgeTypeId); - if (target.kind === "reflection") { - for (const selector of target.selectors) - remember("reflection:" + selector, portableReflectionValue(workspace, selector)); - } else if (target.kind === "state") storage(target.slotId); - else if (target.kind === "edge") storage(target.edgeTypeId); - else if (target.kind === "query") query(target.packageRevisionId, target.queryId); - else if (target.kind === "interface" && port.requirement.kind === "interface") { - const iface = workspace.interfaceImports.find((i) => i.revisionId === target.interfaceRevisionId)!; - for (const targetAtom of targets(target, atom)) - for (const op of port.requirement.operations ?? - iface.members.flatMap((m) => m.operations.map((o) => o.id))) - visit(JSON.stringify([targetAtom, target.interfaceRevisionId, op])); - } else if (target.kind === "creation") { - for (const seed of target.owners) { - const owner = workspace.conformances.find( - (c) => c.atomId === target.atomId && c.interfaceRevisionId === seed.interfaceRevisionId, - )!; - remember(`birth:${owner.id}`, { - id: owner.id, - atom: owner.atomId, - interface: owner.interfaceRevisionId, - initialization: owner.initialization ?? [], - }); - for (const attachment of owner.privateAttachments) storage(attachment.id); - } - } else - throw new ExecutionPlanError( - "GROUP_DEPENDENCY_UNSUPPORTED", - bound.portId, - "Cannot establish execution reuse for this dependency", - ); - } - }; - const visit = (key: string) => { - if (visited.has(key)) return; - visited.add(key); - const operation = operations.get(key); - if (!operation) throw new ExecutionPlanError("GROUP_OPERATION_MISSING", key, "Missing consumed operation"); - const binding = operation.binding; - if (binding.kind === "package") { - const pkg = packages.get(binding.packageRevisionId)!; - const implementation = implementations.get(pkg.revisionId); - if (!implementation) - throw new ExecutionPlanError( - "GROUP_IMPLEMENTATION_MISSING", - pkg.revisionId, - "Consumed package has no selected server implementation", - ); - consumedPackages.add(pkg.packageId); - remember(`implementation:${pkg.packageId}`, { - digest: implementation.contentDigest, - environment: environment(pkg.revisionId), - }); - const entry = pkg.exports.find((e) => e.id === binding.exportId)!; - const bindings = binding.dependencies.map((d) => ({ portId: d.portId, binding: bindingContract(d.binding) })); - const acceptance = (binding.acceptanceDependencies ?? []).map((d) => ({ - portId: d.portId, - binding: bindingContract(d.binding), - })); - const contract = { - input: operation.input, - output: operation.output, - execution: operation.execution, - scope: operation.scope, - binding: { - package: pkg.packageId, - export: binding.exportId, - dependencies: bindings, - acceptanceDependencies: acceptance, - }, - }; - // Deduplicate aliases of the same bound export, rather than keying its owner by reverse callers. - remember(`binding:${hexDigest(contract)}`, contract); - dependencies(entry, binding.dependencies, operation.atomId); - if (entry.acceptanceExportId) - dependencies( - pkg.exports.find((e) => e.id === entry.acceptanceExportId)!, - binding.acceptanceDependencies ?? [], - operation.atomId, - ); - } else { - remember(`operation:${key}`, operation); - if (binding.kind === "state") storage(binding.slotId); - if (binding.kind === "edge") storage(binding.edgeTypeId); - if (binding.kind === "lifecycle") lifecycle(operation.atomId); - } - }; - for (const operation of plan.definition.operations) - if (operation.binding.kind === "package" && operation.binding.packageRevisionId === root.revision) - visit(operation.key); - const own = implementations.get(root.revision)!; - const exports = packages.get(root.revision)!.exports.map(({ displayName: _name, dependencyPorts, ...entry }) => ({ - ...entry, - dependencyPorts: dependencyPorts.map(({ displayName: _name, ...port }) => port), - })); - const entries = [...consumed].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); - return { - logicalId: logical(root.revision), - packageRevision: root.revision, - executionDigest: `sha256:${hexDigest({ implementation: own.contentDigest, exports, environment: environment(root.revision), versions: plan.definition.versions, consumed: entries })}`, - consumedPackages: [...consumedPackages].sort(), - }; + .map((c) => c.atomId as string); + while (pending.length) { + const current = pending.pop()!; + if (visited.has(current)) continue; + visited.add(current); + for (const owner of workspace.conformances.filter((c) => c.atomId === current)) + for (const attachment of owner.privateAttachments) storage(attachment.id); + for (const attachment of attachments.values()) + if (attachment.kind === "edge" && attachment.endpoints.some((e) => atoms(e.constraint).includes(current))) { + storage(attachment.id); + pending.push(...attachment.endpoints.flatMap((e) => atoms(e.constraint))); + } + } + }; + const targets = (binding: DependencyBinding, atom: string): string[] => { + if (binding.kind === "interface" && binding.receiver === "input") + return workspace.conformances + .filter((c) => c.interfaceRevisionId === binding.interfaceRevisionId) + .map((c) => c.atomId); + if (!("via" in binding) || !binding.via) return [atom]; + storage(binding.via.edgeTypeId); + const edge = attachments.get(binding.via.edgeTypeId); + if (!edge || edge.kind !== "edge") + throw new ExecutionPlanError("GROUP_EDGE_MISSING", atom, "Missing traversal edge"); + const endpoint = edge.endpoints.find((e) => e.projectionId !== binding.via!.projectionId)!; + const constraint = endpoint.constraint; + return constraint.kind === "atom" + ? [constraint.atomId] + : workspace.conformances + .filter((c) => c.interfaceRevisionId === constraint.interfaceRevisionId) + .map((c) => c.atomId); + }; + const query = (revision: string, id: string) => { + const key = JSON.stringify([revision, id]); + if (visitedQueries.has(key)) return; + visitedQueries.add(key); + const value = plan.definition.queries.find((q) => q.id === key); + if (!value) throw new ExecutionPlanError("GROUP_QUERY_MISSING", key, "Missing consumed query"); + remember(`query:${JSON.stringify([logical(revision), id])}`, value.query.checked.definitionDigest); + for (const operation of value.operations) visit(operation); + }; + const dependencies = (entry: PackageExport, bindings: readonly BoundDependency[], atom: string) => { + for (const bound of bindings) { + const target = bound.binding, + port = entry.dependencyPorts.find((p) => p.id === bound.portId)!; + if ("via" in target && target.via) storage(target.via.edgeTypeId); + if (target.kind === "reflection") { + for (const selector of target.selectors) + remember("reflection:" + selector, portableReflectionValue(workspace, selector)); + } else if (target.kind === "state") storage(target.slotId); + else if (target.kind === "edge") storage(target.edgeTypeId); + else if (target.kind === "query") query(target.packageRevisionId, target.queryId); + else if (target.kind === "interface" && port.requirement.kind === "interface") { + const iface = workspace.interfaceImports.find((i) => i.revisionId === target.interfaceRevisionId)!; + for (const targetAtom of targets(target, atom)) + for (const op of port.requirement.operations ?? iface.members.flatMap((m) => m.operations.map((o) => o.id))) + visit(JSON.stringify([targetAtom, target.interfaceRevisionId, op])); + } else if (target.kind === "creation") { + for (const seed of target.owners) { + const owner = workspace.conformances.find( + (c) => c.atomId === target.atomId && c.interfaceRevisionId === seed.interfaceRevisionId, + )!; + remember(`birth:${owner.id}`, { + id: owner.id, + atom: owner.atomId, + interface: owner.interfaceRevisionId, + initialization: owner.initialization ?? [], + }); + for (const attachment of owner.privateAttachments) storage(attachment.id); + } + } else + throw new ExecutionPlanError( + "GROUP_DEPENDENCY_UNSUPPORTED", + bound.portId, + "Cannot establish execution reuse for this dependency", + ); + } + }; + const visit = (key: string) => { + if (visited.has(key)) return; + visited.add(key); + const operation = operations.get(key); + if (!operation) throw new ExecutionPlanError("GROUP_OPERATION_MISSING", key, "Missing consumed operation"); + const binding = operation.binding; + if (binding.kind === "package") { + const pkg = packages.get(binding.packageRevisionId)!; + const implementation = implementations.get(pkg.revisionId); + if (!implementation) + throw new ExecutionPlanError( + "GROUP_IMPLEMENTATION_MISSING", + pkg.revisionId, + "Consumed package has no selected server implementation", + ); + consumedPackages.add(pkg.packageId); + remember(`implementation:${pkg.packageId}`, { + digest: implementation.contentDigest, + environment: environment(pkg.revisionId), + }); + const entry = pkg.exports.find((e) => e.id === binding.exportId)!; + const bindings = binding.dependencies.map((d) => ({ portId: d.portId, binding: bindingContract(d.binding) })); + const acceptance = (binding.acceptanceDependencies ?? []).map((d) => ({ + portId: d.portId, + binding: bindingContract(d.binding), + })); + const contract = { + input: operation.input, + output: operation.output, + execution: operation.execution, + scope: operation.scope, + binding: { + package: pkg.packageId, + export: binding.exportId, + dependencies: bindings, + acceptanceDependencies: acceptance, + }, + }; + // Deduplicate aliases of the same bound export, rather than keying its owner by reverse callers. + remember(`binding:${hexDigest(contract)}`, contract); + dependencies(entry, binding.dependencies, operation.atomId); + if (entry.acceptanceExportId) + dependencies( + pkg.exports.find((e) => e.id === entry.acceptanceExportId)!, + binding.acceptanceDependencies ?? [], + operation.atomId, + ); + } else { + remember(`operation:${key}`, operation); + if (binding.kind === "state") storage(binding.slotId); + if (binding.kind === "edge") storage(binding.edgeTypeId); + if (binding.kind === "lifecycle") lifecycle(operation.atomId); + } + }; + for (const operation of plan.definition.operations) + if (operation.binding.kind === "package" && operation.binding.packageRevisionId === root.revision) + visit(operation.key); + const own = implementations.get(root.revision)!; + const exports = packages.get(root.revision)!.exports.map(({ displayName: _name, dependencyPorts, ...entry }) => ({ + ...entry, + dependencyPorts: dependencyPorts.map(({ displayName: _name, ...port }) => port), + })); + const entries = [...consumed].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + groups.push({ + logicalId: logical(root.revision), + packageRevision: root.revision, + executionDigest: `sha256:${await hexDigestAsync({ implementation: own.contentDigest, exports, environment: environment(root.revision), versions: plan.definition.versions, consumed: entries })}`, + consumedPackages: [...consumedPackages].sort(), }); + } + return groups; } 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 0cb526a..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, hexDigest, type ExecutionPlan } from "./plan.js"; + import { publicOperationContract, publicQueryContract, @@ -41,13 +42,13 @@ export interface ClientContractCatalog { }[]; } /** Explicit projection prevents accidental spreading of filesystem roots or private bindings. */ -export function clientContractCatalog( +export async function clientContractCatalog( plan: ExecutionPlan, installation: ExecutionInstallation, requestedPackages: readonly string[], checkedKernel?: InspectionKernel, -): ClientContractCatalog { - if (installation.plan !== plan.digest || hexDigest(plan.definition) !== plan.digest) +): Promise { + if (installation.plan !== plan.digest || (await executionPlanDigestAsync(plan)) !== plan.digest) throw new ExecutionPlanError( "CATALOG_PLAN_MISMATCH", plan.digest, @@ -112,6 +113,7 @@ export function createClientContractCatalog( const revisions = plan.definition.workspace.packageImports.map((pkg) => pkg.revisionId); const installed = new Set(revisions); let catalog: ClientContractCatalog | undefined; + let preparing: Promise | undefined; let release: (() => void) | undefined; let closed = false; const freeze = (value: unknown, seen = new WeakSet()) => { @@ -121,7 +123,7 @@ export function createClientContractCatalog( Object.freeze(value); }; return { - select(requestedPackages: readonly string[]): ClientContractCatalog { + async select(requestedPackages: readonly string[]): Promise { if (closed) throw Error("CANDIDATE_METADATA_CLOSED"); for (const revision of requestedPackages) if (!installed.has(revision)) @@ -129,22 +131,29 @@ export function createClientContractCatalog( if (!catalog) { // Like other candidate admission charges, this is an allocation estimate, // not a V8 heap limit. The process/cgroup ceiling bounds transient work. - const reservation = metadata.reserve(32 * 1024 * 1024); - try { - const compiled = clientContractCatalog(plan, installation, revisions, checkedKernel); - const bytes = Buffer.byteLength(JSON.stringify(compiled)); - if (bytes > 8 * 1024 * 1024) throw Error("CANDIDATE_CATALOG_LIMIT"); - freeze(compiled); - // No await between reservations: replacing the transient upper bound - // with a smaller retained estimate cannot race another admission. - reservation(); - release = metadata.reserve(Math.max(64 * 1024, bytes * 4)); - catalog = compiled; - } catch (error) { - reservation(); - throw error; - } + preparing ??= (async () => { + const reservation = metadata.reserve(32 * 1024 * 1024); + try { + const compiled = await clientContractCatalog(plan, installation, revisions, checkedKernel); + if (closed) throw Error("CANDIDATE_METADATA_CLOSED"); + const bytes = Buffer.byteLength(JSON.stringify(compiled)); + if (bytes > 8 * 1024 * 1024) throw Error("CANDIDATE_CATALOG_LIMIT"); + freeze(compiled); + // No await between reservations: replacing the transient upper bound + // with a smaller retained estimate cannot race another admission. + reservation(); + release = metadata.reserve(Math.max(64 * 1024, bytes * 4)); + catalog = compiled; + } catch (error) { + reservation(); + throw error; + } + })().finally(() => { + preparing = undefined; + }); + await preparing; } + if (closed || !catalog) throw Error("CANDIDATE_METADATA_CLOSED"); const requested = new Set(requestedPackages); return { inspections: catalog.inspections, 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/metadata.ts b/src/execution-world/metadata.ts index 01cf497..a06e35c 100644 --- a/src/execution-world/metadata.ts +++ b/src/execution-world/metadata.ts @@ -22,6 +22,9 @@ export class CandidateMetadataBudget { snapshot() { return { bytes: this.bytes, candidates: this.scopes, owners: this.owners.size, limits: this.limits }; } + ownedBytes(owner: string) { + return this.owners.get(owner) ?? 0; + } acquire(owner: string) { if (!owner || owner.length > 4096) throw Error("INVALID_CANDIDATE_METADATA_OWNER"); if (this.scopes >= this.limits.candidates) throw Error("CANDIDATE_METADATA_LIMIT"); 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 da65c4c..e941965 100644 --- a/src/execution-world/plan.ts +++ b/src/execution-world/plan.ts @@ -11,12 +11,13 @@ 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"; import { unsupportedQueryOperator } from "./query-operators.js"; import { packageBuildContexts } from "./build-contract.js"; +import { setImmediate } from "node:timers/promises"; export class ExecutionPlanError extends Error { constructor( @@ -29,6 +30,56 @@ 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/public-reflection.ts b/src/execution-world/public-reflection.ts index d828670..68f08a1 100644 --- a/src/execution-world/public-reflection.ts +++ b/src/execution-world/public-reflection.ts @@ -1,27 +1,17 @@ -import { compileInspectionTemplates } from "./inspection.js"; -import { compileKernelProgram } from "./kernel.js"; -import { hexDigest, type ExecutionPlan } from "./plan.js"; -import { clientContractCatalog, type ExecutionInstallation } from "./installation.js"; +import { compileInspectionTemplates, type InspectionKernel } from "./inspection.js"; +import { hexDigestAsync, type ExecutionPlan } from "./plan.js"; +import type { ClientContractCatalog } from "./installation.js"; import { publicOperationContract, publicQueryContract } from "./client-contracts.js"; -/** Explicit public projection. Never serialize conformance storage or package ports. */ -export function publicDefinitionSnapshot(plan: ExecutionPlan, installation: ExecutionInstallation) { +/** Explicit public projection of checked inputs. Never serialize conformance + * storage or package ports, or recompile a historical Candidate at runtime. + * The caller supplies that Candidate's checked, cached public catalog. */ +export async function publicDefinitionSnapshot( + plan: ExecutionPlan, + kernel: InspectionKernel, + catalog: ClientContractCatalog, +) { const workspace = plan.definition.workspace; - const kernel = compileKernelProgram(plan) as { - operations: { - atom: string; - interface: string; - operation: string; - unsupported: string | null; - effect: string; - targets: string[]; - }[]; - }; - const catalog = clientContractCatalog( - plan, - installation, - installation.packages.map((pkg) => pkg.revision), - ); const rows = { atoms: workspace.atoms.map((atom) => ({ id: atom.id, name: atom.displayName, documentation: atom.documentation })), interfaces: workspace.interfaceImports.map((iface) => ({ @@ -68,7 +58,7 @@ export function publicDefinitionSnapshot(plan: ExecutionPlan, installation: Exec ), })), queries: [ - ...compileInspectionTemplates(plan).contracts.map((descriptor) => ({ + ...compileInspectionTemplates(plan, kernel).contracts.map((descriptor) => ({ id: JSON.stringify([descriptor.package, descriptor.query]), name: "Public inspection", package: descriptor.package, @@ -100,5 +90,5 @@ export function publicDefinitionSnapshot(plan: ExecutionPlan, installation: Exec { id: string; [key: string]: unknown }[] >; for (const entries of Object.values(publicRows)) entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); - return { codec: "camino-definitions-v1" as const, id: hexDigest(publicRows), rows: publicRows }; + return { codec: "camino-definitions-v1" as const, id: await hexDigestAsync(publicRows), rows: publicRows }; } 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/src/resource-lock/cli.ts b/src/resource-lock/cli.ts index 3562ed8..2d220ff 100644 --- a/src/resource-lock/cli.ts +++ b/src/resource-lock/cli.ts @@ -1,5 +1,20 @@ #!/usr/bin/env node import { loadQuixosLock } from "./loader.js"; +import { parseQuixosLockDocument } from "./parser.js"; +import { readFile, stat } from "node:fs/promises"; + +// A trusted fetcher can inspect the root's declared platform pin without +// following authored import paths or evaluating Nix. The input is a fetched +// Git blob, not a filesystem tree provided by an author. +if (process.argv[2] === "--platform-only") { + const filename = process.argv[3]; + if (process.argv.length !== 4 || !filename || (await stat(filename)).size > 1024 * 1024) + throw Error("INVALID_PLATFORM_SOURCE_LOCK"); + const result = parseQuixosLockDocument(await readFile(filename, "utf8")); + if (!result.ok || result.document.kind !== "root") throw Error("INVALID_PLATFORM_SOURCE_LOCK"); + console.log(JSON.stringify({ repository: result.document.quixos.repository, commit: result.document.quixos.commit })); + process.exit(0); +} const fileName = process.argv[2]; if (!fileName || process.argv.length !== 3) { diff --git a/test/candidate-metadata.test.ts b/test/candidate-metadata.test.ts index 6039c47..112123c 100644 --- a/test/candidate-metadata.test.ts +++ b/test/candidate-metadata.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { CandidateMetadataBudget, readCandidateMetadata } from "../src/execution-world/metadata.js"; import { loadCheckedInstallationCandidate } from "../src/execution-world/candidate.js"; +import { hexDigestAsync } from "../src/execution-world/plan.js"; test( "a real checked application can stage its replacement within the default metadata budget", @@ -21,6 +22,8 @@ test( try { assert.equal(budget.snapshot().bytes, retained * 2); assert.deepEqual(second.reference, first.reference, "Compaction preserves exact checked identity"); + assert.equal(first.world, await hexDigestAsync(first.graphExecution)); + assert.equal(second.world, first.world, "Replacement retains the checked execution identity"); assert.equal(second.graphExecution.kernel, second.kernel, "Graph representation shares the validated program"); assert.equal(second.graphExecution.queries, second.queries); // A deliberately tighter pool admits the serving installation but must diff --git a/test/evolution.test.ts b/test/evolution.test.ts index 97ccb97..754fe45 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,39 @@ 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: 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; + 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); diff --git a/test/execution-groups.test.ts b/test/execution-groups.test.ts index 22cc215..938dceb 100644 --- a/test/execution-groups.test.ts +++ b/test/execution-groups.test.ts @@ -45,7 +45,7 @@ const prepare = async (workspace: WorkspaceRevision) => { test("execution reuse ignores world/source/display changes and unrelated packages", async () => { const workspace = await loadReaderWorkspace(), original = await prepare(workspace); - const before = original.groups()[0]!.executionDigest; + const before = (await original.groups())[0]!.executionDigest; workspace.sourceRootCommit = "f".repeat(40); workspace.packageImports[0]!.displayName = "Renamed"; workspace.packageImports[0]!.source.commit = "e".repeat(40); @@ -59,13 +59,13 @@ test("execution reuse ignores world/source/display changes and unrelated package const after = await prepare(workspace); assert.notEqual(after.plan.digest, original.plan.digest); assert.equal( - after.groups().find((g) => g.logicalId === workspace.packageImports[0]!.packageId)!.executionDigest, + (await after.groups()).find((g) => g.logicalId === workspace.packageImports[0]!.packageId)!.executionDigest, before, ); original.environments[workspace.packageImports[0]!.revisionId]!.credentialGeneration = "config:2"; - assert.notEqual(original.groups()[0]!.executionDigest, before); + assert.notEqual((await original.groups())[0]!.executionDigest, before); original.environments[workspace.packageImports[0]!.revisionId]!.placement = "module"; - assert.notEqual(original.groups()[0]!.executionDigest, before); + assert.notEqual((await original.groups())[0]!.executionDigest, before); }); test("outbound provider changes invalidate consumers while caller changes preserve providers", async () => { @@ -108,15 +108,15 @@ test("outbound provider changes invalidate consumers while caller changes preser dependencies: [{ portId, binding: { kind: "state", slotId: id.slot("slot:document:content") } }], }; const prepared = await prepare(workspace); - const values = () => new Map(prepared.groups().map((g) => [g.logicalId, g.executionDigest])); - const before = values(); + const values = async () => new Map((await prepared.groups()).map((g) => [g.logicalId, g.executionDigest])); + const before = await values(); prepared.installation.packages.find((p) => p.revision === "package:reader-runtime@1")!.service!.contentDigest = "c".repeat(64); - const callerChanged = values(); + const callerChanged = await values(); assert.equal(callerChanged.get("provider"), before.get("provider")); assert.notEqual(callerChanged.get("package:reader-runtime"), before.get("package:reader-runtime")); prepared.installation.packages.find((p) => p.revision === revision)!.service!.contentDigest = "d".repeat(64); - const providerChanged = values(); + const providerChanged = await values(); assert.notEqual(providerChanged.get("provider"), callerChanged.get("provider")); assert.notEqual(providerChanged.get("package:reader-runtime"), callerChanged.get("package:reader-runtime")); }); diff --git a/test/execution-world.test.ts b/test/execution-world.test.ts index 072d13a..ab2047b 100644 --- a/test/execution-world.test.ts +++ b/test/execution-world.test.ts @@ -7,7 +7,11 @@ import { checkExecutionWorld, checkedWorldDigest, implementationDigest, + clientContractCatalog, hexDigest, + executionPlanDigest, + executionPlanDigestAsync, + sealExecutionPlan, parsePackageArtifacts, artifactPath, type PackageArtifacts, @@ -20,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( @@ -385,7 +437,14 @@ test("public reflection contains nominal contracts and callable descriptors with components: [], routes: {}, }; - const snapshot = publicDefinitionSnapshot(plan, installation); + const kernel = compileKernelProgram(plan) as import("../src/execution-world/inspection.js").InspectionKernel; + const catalog = await clientContractCatalog( + plan, + installation, + installation.packages.map((pkg) => pkg.revision), + kernel, + ); + const snapshot = await publicDefinitionSnapshot(plan, kernel, catalog); assert.ok(snapshot.rows.interfaces.length); assert.ok(snapshot.rows.queries.length); assert.ok(snapshot.rows.conformances.length); @@ -400,5 +459,19 @@ test("public reflection contains nominal contracts and callable descriptors with assert.ok(!publicText.includes('"' + privateKey + '"'), privateKey); assert.ok(publicText.includes('"descriptor"')); assert.ok(publicText.includes('"scope":"class"')); - assert.equal(snapshot.id, publicDefinitionSnapshot(plan, installation).id); + assert.equal(snapshot.id, (await publicDefinitionSnapshot(plan, kernel, catalog)).id); + // Historical checked availability must survive a newer host compiler. If + // reflection silently regenerates the Kernel, this evidence disappears. + const historical = structuredClone(kernel); + const installed = historical.operations.find((operation) => !operation.unsupported)!; + assert.ok(installed); + installed.unsupported = "CHECKED_HISTORICAL_UNSUPPORTED"; + const historicalCatalog = await clientContractCatalog( + plan, + installation, + installation.packages.map((pkg) => pkg.revision), + historical, + ); + const historicalSnapshot = await publicDefinitionSnapshot(plan, historical, historicalCatalog); + assert.match(JSON.stringify(historicalSnapshot.rows.conformances), /CHECKED_HISTORICAL_UNSUPPORTED/); }); diff --git a/test/presentations.test.ts b/test/presentations.test.ts index b9d16c5..29a7d34 100644 --- a/test/presentations.test.ts +++ b/test/presentations.test.ts @@ -249,13 +249,13 @@ test("checked component-only artifacts install without a service and reject miss assert.ok(installation.routes[installation.components[0]!.url]); assert.equal(JSON.stringify(installation.components).includes(root), false); assert.equal(JSON.stringify(installation.components).includes("slot:"), false); - const catalog = clientContractCatalog(plan, installation, [pkg.revision]); + const catalog = await clientContractCatalog(plan, installation, [pkg.revision]); assert.equal(catalog.presentations[0]!.component, "component:card"); assert.equal(catalog.components[0]!.subject, "interface:Data@1"); assert.equal(JSON.stringify(catalog).includes(root), false); assert.equal(JSON.stringify(catalog).includes("bindingSchema"), false); - assert.throws(() => clientContractCatalog(plan, installation, ["absent"]), /UNINSTALLED_PACKAGE/); - assert.throws( + await assert.rejects(() => clientContractCatalog(plan, installation, ["absent"]), /UNINSTALLED_PACKAGE/); + await assert.rejects( () => clientContractCatalog(plan, { ...installation, plan: "bad" }, [pkg.revision]), /CATALOG_PLAN_MISMATCH/, ); @@ -268,14 +268,16 @@ test("checked component-only artifacts install without a service and reject miss compileKernelProgram(plan) as InspectionKernel, ); try { - assert.throws(() => prepared.select(["absent"]), /UNINSTALLED_PACKAGE/); + await assert.rejects(() => prepared.select(["absent"]), /UNINSTALLED_PACKAGE/); assert.equal(scope.bytes, 0, "Unknown requests do not allocate compiler metadata"); - assert.deepEqual(prepared.select([pkg.revision]), catalog); + const [first, coalesced] = await Promise.all([prepared.select([pkg.revision]), prepared.select([pkg.revision])]); + assert.deepEqual(first, catalog); + assert.equal(first.operations, coalesced.operations, "Concurrent preparation shares one metadata reservation"); const retained = scope.bytes; assert.ok(retained > 0); assert.ok(retained < 32 * 1024 * 1024, "Small catalogs retain a proportional metadata charge"); - const full = prepared.select([pkg.revision]); - const empty = prepared.select([]); + const full = await prepared.select([pkg.revision]); + const empty = await prepared.select([]); assert.deepEqual(empty.components, []); assert.deepEqual(empty.presentations, []); assert.deepEqual(empty.moduleBindings, []); @@ -290,14 +292,14 @@ test("checked component-only artifacts install without a service and reject miss }, }); try { - assert.deepEqual(prepared.select([pkg.revision]), full); + assert.deepEqual(await prepared.select([pkg.revision]), full); assert.equal(scope.bytes, retained, "Package projections do not accumulate cache entries"); } finally { Object.defineProperty(plan, "definition", { configurable: true, value: definition }); } prepared.close(); assert.equal(scope.bytes, 0); - assert.throws(() => prepared.select([pkg.revision]), /CANDIDATE_METADATA_CLOSED/); + await assert.rejects(() => prepared.select([pkg.revision]), /CANDIDATE_METADATA_CLOSED/); } finally { prepared.close(); scope.release(); @@ -305,7 +307,7 @@ test("checked component-only artifacts install without a service and reject miss const tight = new CandidateMetadataBudget({ bytes: 1000, ownerBytes: 1000, candidates: 1 }); const tightScope = tight.acquire("catalog-test"); const refused = createClientContractCatalog(plan, installation, tightScope); - assert.throws(() => refused.select([pkg.revision]), /CANDIDATE_METADATA_LIMIT/); + await assert.rejects(() => refused.select([pkg.revision]), /CANDIDATE_METADATA_LIMIT/); assert.equal(tightScope.bytes, 0); refused.close(); tightScope.release(); diff --git a/test/source-closure.test.ts b/test/source-closure.test.ts index 4fb8c3e..3cb19d1 100644 --- a/test/source-closure.test.ts +++ b/test/source-closure.test.ts @@ -172,6 +172,27 @@ test("exact source closure recovers through explicitly selected bundles, preserv directory: path.join(temporary, "b"), }); assert.notEqual(a.workspaceId, b.workspaceId); + const assigned = "f8f2f8a3-328c-45a2-847a-31869ad28ce7"; + const c = await prepareDevelopmentSources({ + source: root, + cacheDirectory: directory, + directory: path.join(temporary, "assigned"), + workspaceId: assigned, + }); + assert.equal(c.workspaceId, assigned); + assert.equal( + JSON.parse(await readFile(path.join(c.directory, ".quixos/resource-graph.json"), "utf8")).workspaceId, + assigned, + ); + await assert.rejects( + prepareDevelopmentSources({ + source: root, + cacheDirectory: directory, + directory: path.join(temporary, "invalid"), + workspaceId: "not-a-workspace", + }), + /Workspace UUID/, + ); const aRoot = path.join(a.directory, "root"), bRoot = path.join(b.directory, "root"); assert.equal((await exec("git", ["remote", "get-url", "origin"], { cwd: aRoot })).stdout.trim(), root.repository);