diff --git a/PORTABLE_ARTIFACTS.md b/PORTABLE_ARTIFACTS.md index 0f4618a..55a35a3 100644 --- a/PORTABLE_ARTIFACTS.md +++ b/PORTABLE_ARTIFACTS.md @@ -8,9 +8,9 @@ receipts. It has no migration step or package-defined shell hooks. ```nix # Inside a per-system flake output: quixosPackages.checkedArtifacts = - { plan, generator, core, sharedRuntime, typescript, packageRevisionId }: + { buildContract, sourceDigest, generator, core, sharedRuntime, typescript, packageRevisionId, ... }: helpers.mkCaminoArtifacts { - inherit pkgs plan core sharedRuntime typescript packageRevisionId; + inherit pkgs buildContract sourceDigest core sharedRuntime typescript packageRevisionId; protocol = generator; src = ./.; portable = { @@ -22,6 +22,15 @@ quixosPackages.checkedArtifacts = }; ``` +`buildContract` is a canonical, source-free capability slice. `sourceDigest` +records the exact selected package source in the final installation manifest; +it does not enter the reusable compilation group. Ordinary source dependencies, +SDKs and React props companions remain explicit Nix inputs. Raw QX and resource +lock files enter through the checked contract and evaluated recipe, and are +excluded from the implementation source tree. The `plan` argument on the low-level +helper is a composition convenience for fixtures; it is projected before building. +Package recipes receive the contract directly and can be checked without a workspace. + The handler entry exports `registry = createRegistry(handlers)` from the generated bindings. One registry contains many exports. The builder always supplies a server implementation for the generated process service. A service-only package instead diff --git a/artifacts.nix b/artifacts.nix index 8e2542a..dabb747 100644 --- a/artifacts.nix +++ b/artifacts.nix @@ -2,7 +2,25 @@ { pkgs, src, - plan, + plan ? null, + buildContract ? + (import ./package-context.nix { + inherit + pkgs + protocol + plan + packageRevisionId + ; + }).buildContract, + sourceDigest ? + (import ./package-context.nix { + inherit + pkgs + protocol + plan + packageRevisionId + ; + }).sourceDigest, protocol, core, sharedRuntime, @@ -20,15 +38,15 @@ let config = pkgs.writeText "camino-artifact-build.json" ( builtins.toJSON { inherit - plan + buildContract compiler packageRevisionId core sharedRuntime nodeModules service - group ; + group = true; components = if components == null then null @@ -39,7 +57,10 @@ let bindingOutput = components.bindingOutput or "src/gen/client.ts"; contracts = components.contracts or [ ]; }; - source = src; + source = import ./implementation-source.nix { + inherit (pkgs) lib; + inherit src; + }; portable = if portable == null then null @@ -71,7 +92,27 @@ let serviceAdapter = ./portable-service.mjs; } ); + compiled = pkgs.runCommand "camino-compiled-artifacts" { passthru.buildConfig = config; } '' + ${pkgs.nodejs_24}/bin/node ${./.}/build-artifacts.mjs ${config} + ''; in -pkgs.runCommand "camino-checked-artifacts" { passthru.buildConfig = config; } '' - ${pkgs.nodejs_24}/bin/node ${./.}/build-artifacts.mjs ${config} -'' +if group then + compiled +else + (import ./assemble-package.nix { + inherit pkgs; + artifacts = [ compiled ]; + context = { + inherit + buildContract + sourceDigest + packageRevisionId + sharedRuntime + ; + generator = protocol; + }; + }) + // { + caminoGroup = compiled; + buildConfig = config; + } diff --git a/assemble-package.mjs b/assemble-package.mjs index 56e226e..04a99c7 100644 --- a/assemble-package.mjs +++ b/assemble-package.mjs @@ -6,9 +6,10 @@ import { buildService } from "./build-service.mjs"; const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); const compiler = await import(pathToFileURL(config.compiler)); -const plan = JSON.parse(await fs.readFile(config.plan, "utf8")); -const pkg = plan.definition.packages.find((pkg) => pkg.revision === config.packageRevisionId); -if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid package assembly context"); +const contract = JSON.parse(await fs.readFile(config.buildContract, "utf8")); +compiler.checkPackageBuildContract(contract); +const pkg = contract.definition.package; +if (pkg.revision !== config.packageRevisionId) throw Error("Invalid package assembly context"); const output = process.env.out; if (!output) throw Error("Package assembly requires an output directory"); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); @@ -23,18 +24,17 @@ const recordFile = async (name, mediaType) => { }; await fs.mkdir(path.join(output, "share/quixos/generated"), { recursive: true }); for (const root of config.groups) { - const manifest = compiler.parsePackageArtifacts( + const manifest = compiler.parseArtifactGroup( JSON.parse(await fs.readFile(path.join(root, "share/quixos/package-artifacts.json"), "utf8")), ); if ( manifest.packageRevisionId !== pkg.revision || - manifest.bindingDigest !== pkg.bindingDigest || - manifest.sourceDigest !== pkg.sourceDigest || - JSON.stringify(manifest.versions) !== JSON.stringify(plan.definition.versions) + manifest.bindingDigest !== contract.digest || + JSON.stringify(manifest.versions) !== JSON.stringify(contract.definition.versions) ) throw Error("Artifact group belongs to a different checked package"); const receipt = JSON.parse(await fs.readFile(path.join(root, "share/quixos/build-receipt.json"), "utf8")); - const expected = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); + const expected = compiler.expectedGroupReceipt(contract, manifest, generatorDigest); if (JSON.stringify(receipt) !== JSON.stringify(expected.receipt)) throw Error("Artifact group has an invalid build receipt"); const read = await compiler.artifactFileReader({ [pkg.revision]: root }); @@ -80,24 +80,18 @@ if (!implementations.some((entry) => entry.compatibility === "quixos-service-v1" const manifest = compiler.parsePackageArtifacts({ schemaVersion: 1, packageRevisionId: pkg.revision, - bindingDigest: pkg.bindingDigest, - sourceDigest: pkg.sourceDigest, - versions: plan.definition.versions, + bindingDigest: contract.digest, + sourceDigest: config.sourceDigest, + versions: contract.definition.versions, implementations, ...(components.length ? { components } : {}), }); -const { receipt, sources } = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); +const { receipt, sources } = compiler.expectedPackageReceipt(contract, manifest, config.sourceDigest, generatorDigest); for (const [name, bytes] of sources) { const actual = await fs.readFile(path.join(output, name)); if (!Buffer.from(bytes).equals(actual)) throw Error("Assembly lacks checked generated source " + name); } await fs.writeFile(path.join(output, "share/quixos/package-artifacts.json"), JSON.stringify(manifest, null, 2)); await fs.writeFile(path.join(output, "share/quixos/build-receipt.json"), JSON.stringify(receipt, null, 2)); -// Check package coverage with the same public validator used at installation. -const packagePlan = { definition: { ...plan.definition, packages: [pkg] }, digest: "" }; -packagePlan.digest = compiler.hexDigest(packagePlan.definition); -await compiler.checkExecutionWorld( - packagePlan, - [manifest], - await compiler.artifactFileReader({ [pkg.revision]: output }), -); +// Source selection is attested here; compilation groups retain their own receipt. +compiler.checkPackageCoverage({ ...pkg, bindingDigest: contract.digest, sourceDigest: config.sourceDigest }, manifest); diff --git a/assemble-package.nix b/assemble-package.nix index 8550622..87e4e5d 100644 --- a/assemble-package.nix +++ b/assemble-package.nix @@ -8,7 +8,12 @@ let config = pkgs.writeText "camino-package-assembly.json" ( builtins.toJSON { inherit groups; - inherit (context) plan packageRevisionId sharedRuntime; + inherit (context) + buildContract + sourceDigest + packageRevisionId + sharedRuntime + ; compiler = "${context.generator}/libexec/quixos-protocol/execution-world.mjs"; node = "${pkgs.nodejs_24}/bin/node"; esbuild = "${pkgs.esbuild}/bin/esbuild"; diff --git a/build-artifacts.mjs b/build-artifacts.mjs index 996ba36..f1bc3c1 100644 --- a/build-artifacts.mjs +++ b/build-artifacts.mjs @@ -10,9 +10,10 @@ import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); const compiler = await import(pathToFileURL(config.compiler)); -const plan = JSON.parse(await fs.readFile(config.plan, "utf8")); -const pkg = plan.definition.packages.find((p) => p.revision === config.packageRevisionId); -if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid checked execution plan/package"); +const contract = JSON.parse(await fs.readFile(config.buildContract, "utf8")); +compiler.checkPackageBuildContract(contract); +const pkg = contract.definition.package; +if (pkg.revision !== config.packageRevisionId) throw Error("Invalid build contract package"); const output = process.env.out; if (!output) throw Error("Missing Nix output"); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); @@ -86,8 +87,8 @@ for (const { target, mode, settings } of builds) { const bindingPath = relative(settings.bindingOutput); const source = mode === "remote" - ? compiler.generateRemoteTypeScriptBindings(plan.definition.bindingSchema, pkg.revision) - : compiler.generatePortableTypeScriptBindings(plan.definition.bindingSchema, pkg.revision, { + ? compiler.generateRemoteTypeScriptBindings(contract.definition.bindings, pkg.revision) + : compiler.generatePortableTypeScriptBindings(contract.definition.bindings, pkg.revision, { target: executionTarget, }); await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true }); @@ -179,22 +180,21 @@ for (const { target, mode, settings } of builds) { const components = config.group && !config.components ? [] - : await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }); + : await buildComponents({ config, compiler, contract, pkg, output, recordFile, generated }); if (!config.group || config.service.entry) await buildService({ config, compiler, pkg, output, coreServer, sharedFiles, implementations, recordFile }); -const manifest = compiler.parsePackageArtifacts({ +const manifest = compiler.parseArtifactGroup({ schemaVersion: 1, packageRevisionId: pkg.revision, - bindingDigest: pkg.bindingDigest, - sourceDigest: pkg.sourceDigest, - versions: plan.definition.versions, + bindingDigest: contract.digest, + versions: contract.definition.versions, implementations, ...(components.length ? { components } : {}), }); for (const { file, source } of generated.values()) if ((await fs.readFile(file, "utf8")) !== source) throw Error("Generated bindings changed during build"); -const { receipt, sources } = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); +const { receipt, sources } = compiler.expectedGroupReceipt(contract, manifest, generatorDigest); for (const [file, bytes] of sources) if (!Buffer.from(bytes).equals(await fs.readFile(path.join(output, file)))) throw Error("Receipt generation differs from compiled bindings"); diff --git a/build-components.mjs b/build-components.mjs index 193e5ac..d87a825 100644 --- a/build-components.mjs +++ b/build-components.mjs @@ -5,7 +5,7 @@ import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); -export async function buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }) { +export async function buildComponents({ config, compiler, contract, pkg, output, recordFile, generated }) { const settings = config.components; if (!settings) { if (pkg.components?.length) throw Error("Declared components require checked component build settings"); @@ -108,7 +108,7 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec sharedAliases.push("--alias:" + name + "=" + shim, "--external:" + url); } const bindingPath = compiler.artifactPath(settings.bindingOutput), - bindings = compiler.generateComponentClientBindings(plan, pkg.revision), + bindings = contract.definition.component, assets = compiler.componentAssetDeclarations(); await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true }); await fs.writeFile(path.join(work, bindingPath), bindings); @@ -126,10 +126,9 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec for (const root of settings.contracts ?? []) { const companion = JSON.parse(await fs.readFile(path.join(root, "react-contracts.json"), "utf8")); if (companion.schemaVersion !== 1) throw Error("Unsupported React props companion"); - const contracts = plan.definition.workspace.interfaceImports.filter( + const contracts = contract.definition.moduleInterfaces.filter( (iface) => - iface.revisionId === companion.interfaceRevisionId || - iface.application?.definitionId === companion.interfaceRevisionId, + iface.revisionId === companion.interfaceRevisionId || iface.definitionId === companion.interfaceRevisionId, ); if (!contracts.length) continue; for (const [memberId, entry] of Object.entries(companion.members)) { @@ -139,12 +138,7 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec `import type {${entry.export} as ${alias}} from ${JSON.stringify(path.join(root, "types", compiler.artifactPath(entry.path)))};`, ); for (const iface of contracts) { - if ( - !(iface.template?.members ?? iface.members).some( - (member) => member.kind === "module" && member.id === memberId, - ) - ) - throw Error("Props companion names an unknown module member"); + if (!iface.members.includes(memberId)) throw Error("Props companion names an unknown module member"); const key = JSON.stringify([iface.revisionId, memberId]); if (seenProps.has(key)) throw Error("Duplicate React props companion for " + key); seenProps.add(key); diff --git a/implementation-source.nix b/implementation-source.nix new file mode 100644 index 0000000..672db0a --- /dev/null +++ b/implementation-source.nix @@ -0,0 +1,30 @@ +# QX and recursive pins enter through the checked contract and build recipe. +# The compiler receives authored implementation files, not resource provenance. +{ lib, src }: +builtins.path { + path = src; + name = "camino-implementation-source"; + filter = + path: type: + let + name = builtins.baseNameOf path; + in + if type == "directory" then + !(builtins.elem name [ + ".git" + ".jj" + "node_modules" + ]) + else + !(builtins.elem name [ + "flake.nix" + "flake.lock" + "quixos.lock" + "quixos.check.json" + "quixos.react.json" + "yarn-project.nix" + "components.nix" + "contracts.nix" + ]) + && !(lib.hasSuffix ".qx" name); +} diff --git a/package-context.nix b/package-context.nix new file mode 100644 index 0000000..36310f3 --- /dev/null +++ b/package-context.nix @@ -0,0 +1,2 @@ +# The selected protocol owns semantic projection; builders receive its result. +args@{ protocol, ... }: import "${protocol}/share/package-context.nix" args diff --git a/react-contracts.nix b/react-contracts.nix index d9c5bab..a037d3c 100644 --- a/react-contracts.nix +++ b/react-contracts.nix @@ -8,8 +8,23 @@ let config = pkgs.writeText "camino-react-contracts.json" ( builtins.toJSON { - source = src; - interface = context.interface; + source = import ./implementation-source.nix { + inherit (pkgs) lib; + inherit src; + }; + interface = builtins.toFile "camino-interface-contract.json" ( + builtins.unsafeDiscardStringContext ( + builtins.toJSON ( + let + resource = builtins.fromJSON (builtins.readFile context.interface); + in + { + kind = resource.kind; + revision = builtins.removeAttrs resource.revision [ "source" ]; + } + ) + ) + ); inherit members dependencies; reactSdk = context.reactSdk or null; node = "${pkgs.nodejs_24}/bin/node"; diff --git a/react-package.nix b/react-package.nix index e76ad74..4a99309 100644 --- a/react-package.nix +++ b/react-package.nix @@ -10,7 +10,7 @@ let group = import ./artifacts.nix { inherit pkgs src; inherit (context) - plan + buildContract core sharedRuntime typescript diff --git a/typescript-package.nix b/typescript-package.nix index 950dcbb..09fcaae 100644 --- a/typescript-package.nix +++ b/typescript-package.nix @@ -15,7 +15,7 @@ let group = import ./artifacts.nix { inherit pkgs src; inherit (context) - plan + buildContract core sharedRuntime typescript diff --git a/typescript-service.nix b/typescript-service.nix index 61ceea6..587b60a 100644 --- a/typescript-service.nix +++ b/typescript-service.nix @@ -11,7 +11,7 @@ let group = import ./artifacts.nix { inherit pkgs src service; inherit (context) - plan + buildContract core sharedRuntime typescript