From 1b296daf426d005d32976daef8a85dbe32c7008c Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Tue, 22 Sep 2026 18:11:54 -0700 Subject: [PATCH] Separate graph execution identity from installation provenance --- nix/check-execution.mjs | 3 + src/bindings/portable-client.ts | 12 +-- src/capability-model/generic-packages.ts | 3 +- src/capability-model/generics.ts | 4 +- src/execution-world/candidate.ts | 18 +++- src/execution-world/graph-execution.ts | 59 +++++++++++++ src/execution-world/index.ts | 2 + src/execution-world/installation.ts | 2 + src/execution-world/kernel.ts | 21 ++++- src/execution-world/queries.ts | 3 +- test/generic-packages.test.ts | 23 +++++ test/generics.test.ts | 6 +- test/graph-execution.test.ts | 107 +++++++++++++++++++++++ 13 files changed, 243 insertions(+), 20 deletions(-) create mode 100644 src/execution-world/graph-execution.ts create mode 100644 test/graph-execution.test.ts diff --git a/nix/check-execution.mjs b/nix/check-execution.mjs index c8ba1f0..698f3de 100644 --- a/nix/check-execution.mjs +++ b/nix/check-execution.mjs @@ -25,7 +25,9 @@ const checked = await compiler.loadCheckedArtifactWorld(plan, roots, generator); const installation = await compiler.loadExecutionInstallation(plan, roots); const kernel = compiler.compileKernelProgram(plan), queries = compiler.compileQueryProgram(plan); +const graph = compiler.graphExecutionRepresentation(plan, installation, kernel, queries); for (const [file, value] of [ + ["graph-execution.json", graph], ["execution-world.json", checked.world], ["installation.json", installation], ["kernel.json", kernel], @@ -37,6 +39,7 @@ for (const [file, value] of [ generator, roots, manifests: checked.manifests, + execution: compiler.hexDigest(graph), programs: { kernel: compiler.hexDigest(kernel), queries: compiler.hexDigest(queries) }, }, ], diff --git a/src/bindings/portable-client.ts b/src/bindings/portable-client.ts index 7ba014b..e2c8bc7 100644 --- a/src/bindings/portable-client.ts +++ b/src/bindings/portable-client.ts @@ -1,3 +1,4 @@ +import { compileKernelProgram } from "../execution-world/kernel.js"; import { hexDigest, type ExecutionPlan } from "../execution-world/plan.js"; import { resultInterfaces } from "../execution-world/projections.js"; import { portableValueSchema, tsType, PortableBindingError } from "./portable.js"; @@ -10,6 +11,7 @@ export function generatePortableClientBindings( options: { target: string; runtimeModule?: string }, ): string { if (hexDigest(plan.definition) !== plan.digest) throw new PortableBindingError("client", "Invalid plan digest"); + const executionPlan = (compileKernelProgram(plan) as { plan: string }).plan; const workspace = plan.definition.workspace, atoms = new Map(), unavailable: { key: string; reason: string }[] = []; @@ -39,7 +41,7 @@ export function generatePortableClientBindings( const roots = resultInterfaces(plan, operation.key); const descriptor = { key: operation.key, - plan: plan.digest, + plan: executionPlan, effect: operation.execution.kind, input: portableValueSchema(operation.input), output: portableValueSchema(operation.output), @@ -91,7 +93,7 @@ export function generatePortableClientBindings( JSON.stringify({ kind: "server-projection", id: query.id, - plan: plan.digest, + plan: executionPlan, root: declaration.root.interface, rootKind: declaration.root.kind, }) + @@ -108,7 +110,7 @@ export function generatePortableClientBindings( if (declaration.placement.kind === "local") { const list = views.get(pkg.displayName) ?? []; list.push( - `${JSON.stringify(declaration.displayName)}: ${JSON.stringify({ id: query.id, plan: plan.digest, source: JSON.stringify([query.query.packageRevisionId, declaration.placement.sourceQueryId]) })} as const satisfies LocalQueryDescriptor`, + `${JSON.stringify(declaration.displayName)}: ${JSON.stringify({ id: query.id, plan: executionPlan, source: JSON.stringify([query.query.packageRevisionId, declaration.placement.sourceQueryId]) })} as const satisfies LocalQueryDescriptor`, ); views.set(pkg.displayName, list); addResult(); @@ -116,11 +118,11 @@ export function generatePortableClientBindings( } const list = queries.get(pkg.displayName) ?? []; list.push( - `${JSON.stringify(declaration.displayName)}: { id: ${JSON.stringify(query.id)}, plan: ${JSON.stringify(plan.digest)}, root: ${JSON.stringify(declaration.root.interface)}, rootKind: ${JSON.stringify(declaration.root.kind)} } as const satisfies ReplicaProjection<${JSON.stringify(declaration.root.interface)}, ${JSON.stringify(declaration.root.kind)}>`, + `${JSON.stringify(declaration.displayName)}: { id: ${JSON.stringify(query.id)}, plan: ${JSON.stringify(executionPlan)}, root: ${JSON.stringify(declaration.root.interface)}, rootKind: ${JSON.stringify(declaration.root.kind)} } as const satisfies ReplicaProjection<${JSON.stringify(declaration.root.interface)}, ${JSON.stringify(declaration.root.kind)}>`, ); queries.set(pkg.displayName, list); } const groups = (entries: Map) => [...entries].map(([name, values]) => `${JSON.stringify(name)}: {${values.join(",\n")}}`).join(",\n"); - return `// Generated application descriptors. Regenerate; do not edit.\nimport { defineOperationDescriptor, decodeArgument, workspaceId, type Json, type PreparedChange, type ManagedReference, type ReplicaProjection, type ServerProjection, type LocalQueryDescriptor } from ${JSON.stringify(options.runtimeModule ?? "@quixos/camino-replica-core")};\nexport const planDigest = ${JSON.stringify(plan.digest)};\nexport const operations = {${groups(atoms)}} as const;\nexport const queries = {${groups(queries)}} as const;\nexport const views = {${groups(views)}} as const;\nexport const serverProjections = {${groups(serverProjections)}} as const;\nexport const results = {${groups(results)}} as const;\nexport const unavailable = ${JSON.stringify(unavailable)} as const;\n`; + return `// Generated application descriptors. Regenerate; do not edit.\nimport { defineOperationDescriptor, decodeArgument, workspaceId, type Json, type PreparedChange, type ManagedReference, type ReplicaProjection, type ServerProjection, type LocalQueryDescriptor } from ${JSON.stringify(options.runtimeModule ?? "@quixos/camino-replica-core")};\nexport const planDigest = ${JSON.stringify(executionPlan)};\nexport const operations = {${groups(atoms)}} as const;\nexport const queries = {${groups(queries)}} as const;\nexport const views = {${groups(views)}} as const;\nexport const serverProjections = {${groups(serverProjections)}} as const;\nexport const results = {${groups(results)}} as const;\nexport const unavailable = ${JSON.stringify(unavailable)} as const;\n`; } diff --git a/src/capability-model/generic-packages.ts b/src/capability-model/generic-packages.ts index a71f0e7..ac3e953 100644 --- a/src/capability-model/generic-packages.ts +++ b/src/capability-model/generic-packages.ts @@ -75,9 +75,8 @@ export const specializePackageExport = ( const digest = createHash("sha256") .update( JSON.stringify([ - "quixos-package-specialization-v1", + "quixos-package-specialization-v2", pkg.revisionId, - [pkg.source.repository, pkg.source.commit], definition.id, arguments_.map(canonicalTypeArgument), environment.self ?? null, diff --git a/src/capability-model/generics.ts b/src/capability-model/generics.ts index 8e4501d..9be1e74 100644 --- a/src/capability-model/generics.ts +++ b/src/capability-model/generics.ts @@ -258,10 +258,8 @@ export interface AppliedInterfaceIdentity { export const appliedInterfaceId = (application: AppliedInterfaceIdentity): InterfaceRevisionId => { const encoding = JSON.stringify([ - "quixos-applied-interface-v1", + "quixos-applied-interface-v2", application.definitionId, - application.source.repository, - application.source.commit.toLowerCase(), application.arguments.map(canonicalTypeArgument), application.self ?? null, ]); diff --git a/src/execution-world/candidate.ts b/src/execution-world/candidate.ts index 694ba48..1be6d37 100644 --- a/src/execution-world/candidate.ts +++ b/src/execution-world/candidate.ts @@ -1,3 +1,4 @@ +import { graphExecutionRepresentation } from "./graph-execution.js"; import { open, lstat } from "node:fs/promises"; import { constants } from "node:fs"; import { join } from "node:path"; @@ -24,7 +25,7 @@ export async function loadCheckedInstallationCandidate( ) { if (!rootPattern.test(root) || !(await lstat(root)).isDirectory()) throw Error("Candidate must be an immutable Nix output directory"); - const [candidate, plan, artifacts, source, entryRequest, kernel, queries] = await Promise.all([ + const [candidate, plan, artifacts, source, entryRequest, kernel, queries, graph] = await Promise.all([ readJson(root, "candidate.json"), readJson(root, "execution-plan.json"), readJson(root, "execution-artifacts.json"), @@ -32,6 +33,7 @@ export async function loadCheckedInstallationCandidate( readJson(root, "entry.json"), readJson(root, "kernel.json"), readJson(root, "queries.json"), + readJson(root, "graph-execution.json"), ]); const { executionContracts: _legacyContracts, ...workspace } = candidate; if ( @@ -81,12 +83,19 @@ export async function loadCheckedInstallationCandidate( ["kernel", kernel, "camino-kernel-program-v1"], ["queries", queries, "camino-query-program-v1"], ] as const) - if (program?.codec !== codec || program.plan !== plan.digest || hexDigest(program) !== artifacts.programs?.[name]) + if (program?.codec !== codec || program.plan !== kernel.plan || hexDigest(program) !== artifacts.programs?.[name]) throw new ExecutionPlanError( "CANDIDATE_PROGRAM_MISMATCH", name, "Stored interpreter input must match the checked build and plan", ); + const expectedGraph = graphExecutionRepresentation(plan, installation, kernel, queries); + if (hexDigest(graph) !== hexDigest(expectedGraph) || artifacts.execution !== hexDigest(graph)) + throw new ExecutionPlanError( + "CANDIDATE_EXECUTION_MISMATCH", + root, + "Stored graph execution differs from checked inputs", + ); const provenance = { workspaceRevision: workspace.id as string, source: { repository: source.repository as string, commit: source.commit as string }, @@ -110,8 +119,9 @@ export async function loadCheckedInstallationCandidate( return { reference, workspace: plan.definition.workspace.workspaceId, - world: installation.world, - plan: plan.digest, + world: hexDigest(graph), + plan: kernel.plan as string, + graphExecution: graph, provenance, entry, kernel, diff --git a/src/execution-world/graph-execution.ts b/src/execution-world/graph-execution.ts new file mode 100644 index 0000000..93fb031 --- /dev/null +++ b/src/execution-world/graph-execution.ts @@ -0,0 +1,59 @@ +import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js"; +import type { ExecutionInstallation } from "./installation.js"; + +/** Execution inputs, not build evidence. Nominal revision IDs remain dispatch + * keys: replacing a contract with a structurally similar one is not continuity. + * Host placement/policy/credentials are compared separately by execution groups. */ +export function graphExecutionRepresentation( + plan: ExecutionPlan, + installation: ExecutionInstallation, + kernel: unknown, + queries: unknown, +) { + if (installation.plan !== plan.digest || hexDigest(plan.definition) !== plan.digest) + throw new ExecutionPlanError("GRAPH_PLAN_MISMATCH", "graph", "Expected one checked installation"); + if ( + !kernel || + typeof kernel !== "object" || + Array.isArray(kernel) || + !queries || + typeof queries !== "object" || + Array.isArray(queries) + ) + throw new ExecutionPlanError("GRAPH_PROGRAM_MISMATCH", "graph", "Expected interpreter program records"); + const input = kernel as Record, + queryInput = queries as Record; + const { plan: key, ...program } = input; + if ( + input.codec !== "camino-kernel-program-v1" || + queryInput.codec !== "camino-query-program-v1" || + queryInput.plan !== key || + key !== hexDigest({ versions: plan.definition.versions, program }) + ) + throw new ExecutionPlanError("GRAPH_PROGRAM_MISMATCH", "graph", "Interpreter inputs do not match execution key"); + return { + codec: "camino-graph-execution-v1", + versions: plan.definition.versions, + kernel, + queries, + // Include every selected implementation, including acceptance/remote code + // and the browser dependency closure, not merely the public export names. + packages: installation.packages.map((pkg) => ({ + revision: pkg.revision, + service: pkg.service ?? null, + module: pkg.module ?? null, + browser: pkg.browserImplementation ?? null, + })), + controllers: plan.definition.packages.map((pkg) => ({ + revision: pkg.revision, + controllers: pkg.controllers ?? [], + })), + shared: { + core: installation.coreURL ?? null, + engine: installation.engineURL ?? null, + components: [ + ...new Map(installation.components.flatMap((component) => Object.entries(component.sharedModules))).entries(), + ].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + }, + }; +} diff --git a/src/execution-world/index.ts b/src/execution-world/index.ts index b60b9f4..dd25a52 100644 --- a/src/execution-world/index.ts +++ b/src/execution-world/index.ts @@ -19,3 +19,5 @@ export * from "./candidate.js"; export { reviewSemanticMajors } from "../capability-model/evolution.js"; export * from "./public-reflection.js"; + +export * from "./graph-execution.js"; diff --git a/src/execution-world/installation.ts b/src/execution-world/installation.ts index fce93a4..5d41ec4 100644 --- a/src/execution-world/installation.ts +++ b/src/execution-world/installation.ts @@ -112,6 +112,7 @@ export interface ExecutionInstallation { readonly root: string; readonly service?: ArtifactImplementation; readonly module?: ArtifactImplementation; + readonly browserImplementation?: ArtifactImplementation; readonly browserURL?: string; readonly browserRegistry?: string; }[]; @@ -270,6 +271,7 @@ function assembleExecutionInstallation( root, ...(service ? { service } : {}), ...(module ? { module } : {}), + browserImplementation: browser, browserURL: contentArtifactUrl(entry), browserRegistry: browser.registryExport, }); diff --git a/src/execution-world/kernel.ts b/src/execution-world/kernel.ts index fd338b7..df82847 100644 --- a/src/execution-world/kernel.ts +++ b/src/execution-world/kernel.ts @@ -36,7 +36,7 @@ export function compileKernelProgram(plan: ExecutionPlan): unknown { } }; const ownerBySlot = new Map(); - const edges: unknown[] = []; + const edges: { id: string; owner: string; a: unknown; b: unknown }[] = []; const owners = plan.definition.owners.map((owner) => { const slots: unknown[] = []; for (const storage of owner.storage as PersistentAttachment[]) { @@ -166,7 +166,19 @@ export function compileKernelProgram(plan: ExecutionPlan): unknown { kind: "creation", atom: bound.atomId, schema: portableValueSchema(requirement.inputType), - recipe: hexDigest({ plan: plan.digest, operation: operation.key, port: dependency.portId }), + recipe: hexDigest({ + codec: "camino-creation-recipe-v1", + versions: plan.definition.versions, + operation: operation.key, + export: entry.id, + port: dependency.portId, + requirement, + binding: bound, + owners: owners.filter((owner) => owner.atom === bound.atomId), + edges: edges.filter((edge) => + owners.some((owner) => owner.atom === bound.atomId && owner.id === edge.owner), + ), + }), owners: bound.owners.map((seed) => { const owner = owners.find((o) => o.atom === bound.atomId && o.interface === seed.interfaceRevisionId); if (!owner) @@ -230,11 +242,12 @@ export function compileKernelProgram(plan: ExecutionPlan): unknown { output: schema(entry.outputType), })), })); - return { + const body = { codec: "camino-kernel-program-v1", - plan: plan.digest, model: { workspace: workspace.workspaceId, atoms: workspace.atoms.map((atom) => atom.id), owners, edges }, operations, packages, }; + // The interpreter's plan key covers its real input, not source/build evidence. + return { ...body, plan: hexDigest({ versions: plan.definition.versions, program: body }) }; } diff --git a/src/execution-world/queries.ts b/src/execution-world/queries.ts index aff4ac9..fdf62d0 100644 --- a/src/execution-world/queries.ts +++ b/src/execution-world/queries.ts @@ -1,3 +1,4 @@ +import { compileKernelProgram } from "./kernel.js"; import { compileInspectionTemplates } from "./inspection.js"; import { portableValueSchema } from "../bindings/portable.js"; import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js"; @@ -26,7 +27,7 @@ export function compileQueryProgram(plan: ExecutionPlan): unknown { const interfaces = plan.definition.workspace.interfaceImports; return { codec: "camino-query-program-v1", - plan: plan.digest, + plan: (compileKernelProgram(plan) as { plan: string }).plan, queries: [ ...compileInspectionTemplates(plan).queries, ...plan.definition.queries.map(({ id, query, admittedOperations }) => { diff --git a/test/generic-packages.test.ts b/test/generic-packages.test.ts index 650d50c..994b622 100644 --- a/test/generic-packages.test.ts +++ b/test/generic-packages.test.ts @@ -1,3 +1,4 @@ +import { specializePackageExport } from "../src/capability-model/generic-packages.js"; import assert from "node:assert/strict"; import test from "node:test"; import fs from "node:fs/promises"; @@ -297,3 +298,25 @@ test("typed presentation and factory consumers preserve distinct closed applicat await fs.rm(directory, { recursive: true, force: true }); } }); + +test("specialized dispatch keys retain nominal arguments, not build provenance", () => { + const pkg = definition(); + const generic = pkg.genericExports![0]!; + const environment = { + arguments: new Map(), + applyInterface: () => { + throw Error("unused"); + }, + implementsInterface: () => false, + }; + const arguments_ = [{ kind: "value" as const, type: { kind: "scalar" as const, name: "string" as const } }]; + const original = specializePackageExport(pkg, generic, arguments_, environment); + const rebuilt = structuredClone(pkg); + rebuilt.source.commit = "f".repeat(40); + assert.deepEqual(specializePackageExport(rebuilt, generic, arguments_, environment), original); + assert.notEqual( + specializePackageExport(rebuilt, generic, [{ kind: "value", type: { kind: "scalar", name: "bool" } }], environment) + .id, + original.id, + ); +}); diff --git a/test/generics.test.ts b/test/generics.test.ts index 8cef6b4..6eadb45 100644 --- a/test/generics.test.ts +++ b/test/generics.test.ts @@ -165,7 +165,7 @@ test("bounds require evidence and storable constraints inspect nested values", ( ); }); -test("closed applications canonicalize records without erasing nominal identity or provenance", () => { +test("closed applications preserve nominal identity while separating source provenance", () => { const a: ClosedTypeArgument = { kind: "value", type: { kind: "record", fields: { b: valueType.int64, a: valueType.string } }, @@ -183,6 +183,10 @@ test("closed applications canonicalize records without erasing nominal identity assert.equal(appliedInterfaceId(application), appliedInterfaceId({ ...application, arguments: [b] })); assert.notEqual(appliedInterfaceId(application), appliedInterfaceId({ ...application, self: note })); assert.notEqual( + appliedInterfaceId(application), + appliedInterfaceId({ ...application, definitionId: capabilityId.interfaceRevision("other@1") }), + ); + assert.equal( appliedInterfaceId(application), appliedInterfaceId({ ...application, source: { ...application.source, commit: "b".repeat(40) } }), ); diff --git a/test/graph-execution.test.ts b/test/graph-execution.test.ts new file mode 100644 index 0000000..ba6f8ac --- /dev/null +++ b/test/graph-execution.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { loadReaderWorkspace } from "./reader-fixture.js"; +import { buildArtifactFixture } from "./artifact-fixture.js"; +import { + generatePortableClientBindings, + compileExecutionPlan, + compileKernelProgram, + compileQueryProgram, + graphExecutionRepresentation, + hexDigest, + type ExecutionPlan, + type ExecutionInstallation, +} from "../src/execution-world/index.js"; + +function prepare(plan: ExecutionPlan) { + const installation: ExecutionInstallation = { + codec: "camino-execution-installation-v1", + plan: plan.digest, + world: "0".repeat(64), + routes: {}, + components: [], + packages: plan.definition.packages.map((pkg) => { + const { manifest } = buildArtifactFixture(plan, pkg.revision); + return { + revision: pkg.revision, + root: "/build/provenance", + service: manifest.implementations[0]!, + browserImplementation: manifest.implementations[2]!, + }; + }), + }; + const kernel = compileKernelProgram(plan) as any, + queries = compileQueryProgram(plan) as any; + return { + installation, + kernel, + queries, + graph: () => graphExecutionRepresentation(plan, installation, kernel, queries), + }; +} + +test("actual interpreter inputs and creation recipes exclude source provenance", async () => { + const workspace = await loadReaderWorkspace(); + const originalPlan = compileExecutionPlan(workspace); + const before = prepare(originalPlan); + workspace.sourceRootCommit = "f".repeat(40); + for (const pkg of workspace.packageImports) pkg.source.commit = "e".repeat(40); + const nextPlan = compileExecutionPlan(workspace); + const after = prepare(nextPlan); + assert.equal( + generatePortableClientBindings(originalPlan, { target: "browser" }), + generatePortableClientBindings(nextPlan, { target: "browser" }), + ); + assert.notEqual(before.installation.plan, after.installation.plan); + assert.deepEqual(before.kernel, after.kernel); + assert.deepEqual(before.queries, after.queries); + assert.deepEqual(before.graph(), after.graph()); + assert.ok(JSON.stringify(before.kernel).includes('"recipe"')); +}); + +test("graph identity covers executable closures, query semantics and real interpreter input", async () => { + const plan = compileExecutionPlan(await loadReaderWorkspace()); + const fixture = prepare(plan), + baseline = hexDigest(fixture.graph()); + fixture.installation.packages[0]!.service!.files[0]!.digest = "c".repeat(64); + assert.notEqual(hexDigest(fixture.graph()), baseline); + const query = prepare(plan); + query.queries.queries[0].budgets.rows++; + assert.notEqual(hexDigest(query.graph()), baseline); + query.kernel.operations[0].output = { kind: "scalar", name: "string" }; + assert.throws(() => query.graph(), /GRAPH_PROGRAM_MISMATCH/); + const shared = prepare(plan); + (shared.installation as any).engineURL = "/changed-engine.mjs"; + assert.notEqual(hexDigest(shared.graph()), baseline); + const future = prepare(plan); + future.kernel.codec = "future-kernel"; + assert.throws(() => future.graph(), /GRAPH_PROGRAM_MISMATCH/); +}); + +test("initialization, storage, nominal contracts and bound ports affect executed kernel identity", async () => { + const workspace = await loadReaderWorkspace(), + plan = compileExecutionPlan(workspace); + const original = prepare(plan); + for (const change of [ + (p: ExecutionPlan) => { + (p.definition.owners[0]!.initialization as any[]).push({ + input: "extra", + binding: { kind: "state", slotId: "extra" }, + }); + }, + (p: ExecutionPlan) => { + p.definition.owners[0]!.id += ":different"; + }, + (p: ExecutionPlan) => { + p.definition.operations[0]!.operationId += ":different"; + }, + (p: ExecutionPlan) => { + p.definition.versions.crdt = "future-genesis" as any; + }, + ]) { + const changed = structuredClone(plan); + change(changed); + changed.digest = hexDigest(changed.definition); + assert.notEqual((compileKernelProgram(changed) as any).plan, original.kernel.plan); + } +});