diff --git a/flake.nix b/flake.nix index 7a86d3d..69fa91f 100644 --- a/flake.nix +++ b/flake.nix @@ -39,6 +39,7 @@ export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}" patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin yarn build + esbuild dist/src/execution-world/index.js --bundle --platform=node --target=node24 --format=esm --outfile=execution-world.mjs esbuild dist/src/bindings/client.js --bundle --platform=node --target=node24 --format=esm --outfile=client-codegen.mjs diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated @@ -112,6 +113,7 @@ EOF chmod +x "$out/bin/quixos-lock-check" mkdir -p "$out/libexec/quixos-protocol" + install -m644 execution-world.mjs "$out/libexec/quixos-protocol/execution-world.mjs" install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs" install -m644 react-platform.mjs "$out/libexec/quixos-protocol/react-platform.mjs" install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/" diff --git a/package.json b/package.json index c499da9..742ebb6 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "./capability-model": "./dist/src/capability-model/index.js", "./capability-language": "./dist/src/capability-language/index.js", "./resource-lock": "./dist/src/resource-lock/index.js", - "./gen/*": "./dist/src/gen/*" + "./gen/*": "./dist/src/gen/*", + "./execution-world": "./dist/src/execution-world/index.js" }, "scripts": { "build": "yarn generate && tsc -p tsconfig.build.json", diff --git a/src/capability-language/workspace-cli.ts b/src/capability-language/workspace-cli.ts index f56f80b..e7ddac0 100644 --- a/src/capability-language/workspace-cli.ts +++ b/src/capability-language/workspace-cli.ts @@ -4,6 +4,7 @@ import { readFile, writeFile } from "node:fs/promises"; import process from "node:process"; import { compileWorkspaceRepository } from "./assembly.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; +import { compileExecutionPlan } from "../execution-world/index.js"; import { bindingSchema, specializeBindingSchema } from "../bindings/index.js"; import { planEvolution, @@ -15,7 +16,7 @@ import { const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root DIRECTORY [--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID] [--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH] - [--reviews REVIEW_JSON] [--schemas-out PATH] + [--reviews REVIEW_JSON] [--schemas-out PATH] [--execution-plan-out PATH] Resolves a workspace's recursive resource-lock graph, clones every exact resource revision, validates standalone interface/package manifests, and emits @@ -37,6 +38,7 @@ const parseArgs = (args: string[]) => { checkoutRoot, graphOut: values.get("--graph-out"), schemasOut: values.get("--schemas-out"), + executionPlanOut: values.get("--execution-plan-out"), snapshotMap: values.get("--snapshot-map"), workspaceId: values.get("--workspace-id"), workspaceRevisionId: values.get("--workspace-revision-id"), @@ -98,6 +100,12 @@ const main = async () => { ); } const candidate = { ...assembled.workspace, executionContracts: runtimeContracts(assembled.workspace) }; + if (options.executionPlanOut) { + await writeFile( + options.executionPlanOut, + `${JSON.stringify(compileExecutionPlan(assembled.workspace), null, 2)}\n`, + ); + } if (options.schemasOut) { const schemas: Record = {}; for (const node of assembled.resources.filter((entry) => entry.kind === "package")) { diff --git a/src/execution-world/README.md b/src/execution-world/README.md new file mode 100644 index 0000000..eabbe35 --- /dev/null +++ b/src/execution-world/README.md @@ -0,0 +1,86 @@ +# Closed execution plans and artifacts + +`compileExecutionPlan` validates a workspace, links its checked queries, resolves +public forwarding, and emits one versioned plan with an SHA-256 digest. It retains +exact interface/package revisions and generic applications, owner initialization, +storage definitions/profiles, selected operation identities, query placement, and +source provenance. `quixos-workspace-compile --execution-plan-out PATH` writes it. + +The compiler emits operation dependencies as a graph. A traversed interface port +retains its edge/projection and exact selected operations; native reads identify +the canonical storage inputs. Cycles do not become infinitely expanded paths. +Execution and admission must apply their runtime depth/work budgets when walking +this graph. The plan includes the full linked GraphQL selections, not just root +traversals or a list of fields. + +Local and replica queries must have the same root contract. A local query cannot +introduce a relationship path absent from its replica source; authors expand the +source selection explicitly. Portable getters may require extra canonical fields +and exactly-one dependency traversals, recorded in the dependency graph. Admission +resolves those inputs on the server for selected objects. This never changes the +source query's candidate membership, and does not claim a limited source is a +complete interface extent. An incomplete runtime projection remains a coverage +state, not permission to invent a missing value. + +A portable transaction cannot depend on a local presentation query, including +through a selector. Pass explicit references and preconditions as mutation input. +Otherwise an admitted top-N list could select different mutation targets locally +and at acceptance. Remote transactions can implement explicit server workflows. +This restriction does not ban local queries from snapshot selectors. Such query +ports are view-relative: both hosts must use the frame's admitted candidate +universe. An executor must report unavailable coverage when that view is absent, +not substitute a full-server extent. Query-port acquisition remains unavailable +until the query executor implements this rule; emitting its checked dependency +graph is not runtime support. + +## Artifacts and world identity + +`PackageArtifacts` describes one package output containing a process service and +optional target modules. Implementation IDs are stable manifest keys, separate +from export capability IDs and JavaScript registry names. Each implementation +covers its ABI, export map, entrypoint, and declared asset paths/digests/sizes/MIME. +Its digest excludes its own digest field. Paths must be normalized and relative. + +`checkExecutionWorld` verifies plan identity, supported codecs, exact source and +binding schema, service fallback, promised targets, declared exports, and all +supplied file bytes. The reader must confine paths/symlinks to the checked output +and honor its byte limit before allocation. LF-05 supplies that filesystem adapter, +checks actual module registries/imports, and verifies build receipts; a manifest +claim alone does not establish the JavaScript exports or an isolated process. + +The returned `camino-world-v1` descriptor includes every declared implementation +and its digest, so executor choice does not change semantics or silently load new +code. Each binding key is the JSON pair `[packageRevisionId, implementationId]`; +there can be both a service and module for the server target. World hashing uses +the core codec's canonical ordering and domain-separated SHA-256 framing. The +monorepo `camino-execution-world` check compares the compiler and core implementations. +No machine-local Nix path enters the world descriptor. + +The current binding schema is the closed workspace schema. This is conservative: +an unrelated contract change can require checking/rebuilding several packages. +LF-05 may narrow it to exact checked per-package closures, but must use the same +schema for generation, receipts, and world validation. It must not substitute an +older package schema just because the source revision did not change. + +## Transaction-port ABI + +`quixos-transaction-ports-v1` uses coordinator-issued frame/invocation handles and +monotonic invocation-local sequence numbers. A package receives its export ID, +input, and optional receiver. Port calls name a declared port and its allowed +primitive or exact interface operation; they never select arbitrary private slots, +packages, or coordinator transactions. Creation uses an explicit stable call key. Interface calls into transaction +operations also require a stable key, checked after resolving the selected +operation. The coordinator extends the birth path; it never derives that path +from an asynchronous completion order or the transport sequence number. + +The coordinator resolves each port through the installed operation binding, +checks effects/selected methods, validates typed arguments/references, and joins +nested calls to the outer logical transaction. A nested failure rolls back to its +frame boundary; process completion cannot independently commit. The coordinator +owns savepoints, cancellation, frame expiry, and rejection. These enforcement +mechanisms are LF-06/LF-08, not guarantees provided by TypeScript frame types. + +The service control vocabulary includes readiness, cancellation, and shutdown; +transport framing and generated adapters are LF-05/LF-08. Portable modules and +services implement the same declared operation contracts. ABI compatibility is +not a statement about package trust, co-location permission, or multi-user isolation. diff --git a/src/execution-world/artifacts.ts b/src/execution-world/artifacts.ts new file mode 100644 index 0000000..87b8a73 --- /dev/null +++ b/src/execution-world/artifacts.ts @@ -0,0 +1,281 @@ +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; + digest: string; + bytes: number; + mediaType: string; +} +export interface ArtifactImplementation { + id: string; + target: string; + compatibility: "quixos-service-v1" | "quixos-js-module-v1"; + path: string; + ports: "quixos-transaction-ports-v1"; + registryExport?: string; + exports: string[]; + /** Every executable/imported local asset, including the entrypoint, must be declared. */ + files: ArtifactFile[]; + contentDigest: string; +} +export interface PackageArtifacts { + schemaVersion: 1; + packageRevisionId: string; + bindingDigest: string; + sourceDigest: string; + versions: ExecutionPlan["definition"]["versions"]; + implementations: ArtifactImplementation[]; +} +export interface CheckedWorld { + codec: "camino-world-v1"; + plan: string; + artifacts: { binding: string; target: string; digest: string }[]; + versions: Record; +} +const fail = (path: string, message: string): never => { + throw new ExecutionPlanError("INVALID_ARTIFACT", path, message); +}; +const object = (value: unknown, path: string): Record => { + if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) + fail(path, "Expected a plain JSON record"); + return value as Record; +}; +const keys = (record: Record, required: string[], optional: string[], path: string) => { + if ( + required.some((key) => !Object.hasOwn(record, key)) || + Object.keys(record).some((key) => ![...required, ...optional].includes(key)) + ) + fail(path, "Unexpected or missing fields"); +}; +const text = (value: unknown, path: string): string => { + if ( + typeof value !== "string" || + !value.length || + value.length > 4096 || + value.includes("\0") || + Buffer.from(value, "utf8").toString("utf8") !== value + ) + fail(path, "Expected bounded nonempty text"); + return value as string; +}; +const digest = (value: unknown, path: string): string => { + if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) fail(path, "Expected SHA-256 hex"); + return value as string; +}; +const array = (value: unknown, path: string): unknown[] => { + if (!Array.isArray(value) || value.length > 10000) fail(path, "Expected a bounded array"); + return value as unknown[]; +}; +const unique = (values: string[], path: string) => { + if (new Set(values).size !== values.length) fail(path, "Duplicate identities"); +}; +export function artifactPath(value: unknown): string { + const path = text(value, "artifact path"); + if ( + path.startsWith("/") || + path.includes("\\") || + path.split("/").some((part) => !part || part === "." || part === "..") || + /[\u0000-\u001f\u007f?#%]/.test(path) + ) + fail(path, "Expected a normalized relative artifact path"); + return path; +} +/** No self-reference: the digest covers ABI/export metadata and the digests of every declared file. */ +export function implementationDigest(implementation: Omit): string { + return hexDigest({ + ...implementation, + exports: [...implementation.exports].sort(), + files: [...implementation.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)), + }); +} +export function parsePackageArtifacts(value: unknown): PackageArtifacts { + const record = object(value, "manifest"); + keys( + record, + ["schemaVersion", "packageRevisionId", "bindingDigest", "sourceDigest", "versions", "implementations"], + [], + "manifest", + ); + if (record.schemaVersion !== 1) fail("manifest", "Unsupported artifact manifest version"); + const versions = object(record.versions, "versions"); + keys(versions, ["value", "reference", "ports", "crdt"], [], "versions"); + for (const [key, value] of Object.entries(versions)) text(value, `versions.${key}`); + const implementations = array(record.implementations, "implementations").map( + (value, index): ArtifactImplementation => { + const path = `implementations[${index}]`, + entry = object(value, path); + keys( + entry, + ["id", "target", "compatibility", "path", "ports", "exports", "files", "contentDigest"], + ["registryExport"], + path, + ); + if (entry.compatibility !== "quixos-service-v1" && entry.compatibility !== "quixos-js-module-v1") + fail(path, "Unsupported artifact ABI"); + if (entry.ports !== "quixos-transaction-ports-v1") fail(path, "Unsupported transaction port ABI"); + if ( + entry.compatibility === "quixos-service-v1" && + (entry.target !== "server" || entry.registryExport !== undefined) + ) + fail(path, "Service must target server and has no JS registry export"); + if (entry.compatibility === "quixos-js-module-v1") text(entry.registryExport, `${path}.registryExport`); + const exports = array(entry.exports, `${path}.exports`).map((value) => text(value, `${path}.exports`)); + unique(exports, `${path}.exports`); + const files = array(entry.files, `${path}.files`).map((value): ArtifactFile => { + const file = object(value, `${path}.file`); + keys(file, ["path", "digest", "bytes", "mediaType"], [], `${path}.file`); + if ( + !Number.isSafeInteger(file.bytes) || + (file.bytes as number) < 0 || + (file.bytes as number) > 256 * 1024 * 1024 + ) + fail(path, "Artifact file exceeds size bound"); + return { + path: artifactPath(file.path), + digest: digest(file.digest, path), + bytes: file.bytes as number, + mediaType: text(file.mediaType, path), + }; + }); + unique( + files.map((file) => file.path), + `${path}.files`, + ); + const entryPath = artifactPath(entry.path); + if (!files.some((file) => file.path === entryPath)) fail(path, "Entrypoint is not covered by the file manifest"); + const result: ArtifactImplementation = { + id: text(entry.id, path), + target: text(entry.target, path), + compatibility: entry.compatibility as ArtifactImplementation["compatibility"], + path: entryPath, + ports: "quixos-transaction-ports-v1", + exports: exports.sort(), + files: files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)), + contentDigest: digest(entry.contentDigest, path), + ...(entry.registryExport !== undefined ? { registryExport: entry.registryExport as string } : {}), + }; + const { contentDigest, ...body } = result; + if (implementationDigest(body) !== contentDigest) fail(path, "Implementation metadata digest mismatch"); + return result; + }, + ); + unique( + implementations.map((implementation) => implementation.id), + "implementations", + ); + return { + schemaVersion: 1, + packageRevisionId: text(record.packageRevisionId, "packageRevisionId"), + bindingDigest: digest(record.bindingDigest, "bindingDigest"), + sourceDigest: digest(record.sourceDigest, "sourceDigest"), + versions: versions as unknown as PackageArtifacts["versions"], + implementations, + }; +} +function checkCoverage(expected: PlanPackage, manifest: PackageArtifacts): void { + if (expected.bindingDigest !== manifest.bindingDigest || expected.sourceDigest !== manifest.sourceDigest) + fail(expected.revision, "Substituted binding schema or source revision"); + for (const implementation of manifest.implementations) + for (const id of implementation.exports) { + const entry = expected.exports.find((e) => e.id === id); + if (!entry) fail(implementation.id, `Unknown export ${id}`); + if ( + implementation.target !== "server" && + (!entry?.execution || + entry.execution.kind === "remote" || + !entry.execution.targets.includes(implementation.target)) + ) + fail(implementation.id, `Export ${id} does not promise target ${implementation.target}`); + } + for (const entry of expected.exports) { + if (!manifest.implementations.some((i) => i.compatibility === "quixos-service-v1" && i.exports.includes(entry.id))) + fail(expected.revision, `Missing service fallback for ${entry.id}`); + if (entry.execution.kind !== "remote") + for (const target of entry.execution.targets) + if ( + !manifest.implementations.some( + (i) => + i.target === target && + i.exports.includes(entry.id) && + (target === "server" || i.compatibility !== "quixos-service-v1"), + ) + ) + fail(expected.revision, `Missing ${target} implementation for ${entry.id}`); + } +} +export type ArtifactReader = (packageRevisionId: string, path: string, maxBytes: number) => Promise; +/** Caller supplies a reader confined to the resolved immutable package output, including symlink checks. */ +export async function checkExecutionWorld( + plan: ExecutionPlan, + inputs: readonly unknown[], + read: ArtifactReader, +): 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"); + const manifests = inputs.map(parsePackageArtifacts); + unique( + manifests.map((m) => m.packageRevisionId), + "packages", + ); + if (manifests.length !== plan.definition.packages.length) fail("world", "Package closure differs from plan"); + const artifacts: CheckedWorld["artifacts"] = []; + for (const expected of plan.definition.packages) { + const manifest = + manifests.find((m) => m.packageRevisionId === expected.revision) ?? + fail(expected.revision, "Missing package artifact"); + checkCoverage(expected, manifest); + if (canonicalJson(manifest.versions) !== canonicalJson(plan.definition.versions)) + fail(expected.revision, "Incompatible runtime codecs"); + const files = new Map(); + for (const implementation of manifest.implementations) { + for (const file of implementation.files) { + const existing = files.get(file.path); + if (existing && canonicalJson(existing) !== canonicalJson(file)) + fail(file.path, "Conflicting file descriptors"); + files.set(file.path, file); + } + artifacts.push({ + binding: JSON.stringify([expected.revision, implementation.id]), + target: implementation.target, + digest: implementation.contentDigest, + }); + } + for (const file of files.values()) { + const bytes = await read(expected.revision, file.path, file.bytes); + if (bytes.byteLength !== file.bytes || createHash("sha256").update(bytes).digest("hex") !== file.digest) + fail(file.path, "Artifact bytes do not match the checked content digest"); + } + } + return { + codec: "camino-world-v1", + plan: plan.digest, + artifacts: artifacts.sort((a, b) => + a.binding < b.binding ? -1 : a.binding > b.binding ? 1 : a.target < b.target ? -1 : a.target > b.target ? 1 : 0, + ), + versions: { ...plan.definition.versions }, + }; +} +/** Node counterpart of replica-core worldDigest. The wire fixture checks cross-package agreement. */ +export function checkedWorldDigest(world: CheckedWorld): string { + const normalized = { + ...world, + artifacts: [...world.artifacts].sort((a, b) => + a.binding < b.binding ? -1 : a.binding > b.binding ? 1 : a.target < b.target ? -1 : a.target > b.target ? 1 : 0, + ), + }; + const json = canonicalJson(normalized), + content = createHash("sha256").update(json).digest("hex"); + const components = ["camino-world-v1", content].map((text) => Buffer.from(text, "utf8")); + const count = Buffer.alloc(4); + count.writeUInt32BE(components.length); + const pieces = [count]; + for (const bytes of components) { + const length = Buffer.alloc(4); + length.writeUInt32BE(bytes.length); + pieces.push(length, bytes); + } + return createHash("sha256").update(Buffer.concat(pieces)).digest("hex"); +} diff --git a/src/execution-world/index.ts b/src/execution-world/index.ts new file mode 100644 index 0000000..110c772 --- /dev/null +++ b/src/execution-world/index.ts @@ -0,0 +1,3 @@ +export * from "./plan.js"; +export * from "./artifacts.js"; +export * from "./ports.js"; diff --git a/src/execution-world/plan.ts b/src/execution-world/plan.ts new file mode 100644 index 0000000..f411da7 --- /dev/null +++ b/src/execution-world/plan.ts @@ -0,0 +1,401 @@ +import type { + WorkspaceRevision, + ExecutionContract, + ValueType, + Binding, + DependencyBinding, + EdgeTraversal, + InterfaceRevisionId, + AtomId, + OperationId, +} from "../capability-model/types.js"; +import { validateWorkspaceRevision } from "../capability-model/validation.js"; +import { canonicalJson, contentDigest } 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"; + +export class ExecutionPlanError extends Error { + constructor( + readonly code: string, + readonly path: string, + message: string, + ) { + super(`${code} ${path}: ${message}`); + this.name = "ExecutionPlanError"; + } +} +export const hexDigest = (value: unknown): string => contentDigest(value).slice("sha256:".length); +const sorted = (values: readonly T[], key: (value: T) => string): T[] => + [...values].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0)); +export const publicOperationKey = (atom: string, iface: string, operation: string): string => + JSON.stringify([atom, iface, operation]); +export type DataDependency = + | { kind: "state"; slot: string; via?: EdgeTraversal } + | { kind: "edge"; edge: string; projection: string; via?: EdgeTraversal } + | { kind: "operation"; operation: string; port: string; via?: EdgeTraversal } + | { kind: "query"; query: string; port: string }; +export interface PlannedOperation { + key: string; + atomId: string; + interfaceRevisionId: string; + operationId: string; + scope: "object" | "class"; + execution: ExecutionContract; + input: ValueType; + output: ValueType; + /** Concrete target after public forwarding. Never chosen by a runtime caller. */ + binding: Exclude; + dependencies: DataDependency[]; +} +export interface PlannedQuery { + id: string; + query: LinkedQuery; + /** Query-selected getter entrypoints; dependency traversal supplies canonical inputs. */ + operations: string[]; + /** Local query roots that admission must support, in addition to selected wire outputs. */ + admittedOperations: string[]; +} +export interface PlanPackage { + revision: string; + source: WorkspaceRevision["packageImports"][number]["source"]; + sourceDigest: string; + bindingDigest: string; + exports: { id: string; execution: ExecutionContract }[]; +} +export interface ExecutionPlanDefinition { + codec: "camino-execution-plan-v1"; + workspace: WorkspaceRevision; + bindingSchema: BindingSchema; + packages: PlanPackage[]; + owners: { id: string; atom: string; interface: string; initialization: unknown; storage: unknown }[]; + operations: PlannedOperation[]; + queries: PlannedQuery[]; + profiles: { slot: string; profile: string }[]; + versions: { + value: "camino-value-v1"; + reference: "camino-ref-v1"; + ports: "quixos-transaction-ports-v1"; + crdt: "camino-automerge-3.4.1-structured-v1"; + }; +} +export interface ExecutionPlan { + definition: ExecutionPlanDefinition; + digest: string; +} +export const executionVersions: ExecutionPlanDefinition["versions"] = { + value: "camino-value-v1", + reference: "camino-ref-v1", + ports: "quixos-transaction-ports-v1", + crdt: "camino-automerge-3.4.1-structured-v1", +}; +/** Selection only: the coordinator separately verifies the caller's grant and receiver witness. */ +export function resolvePlannedOperation( + plan: ExecutionPlan, + atom: string, + iface: string, + operation: string, +): PlannedOperation { + const key = publicOperationKey(atom, iface, operation); + const selected = plan.definition.operations.find((entry) => entry.key === key); + if (!selected) throw new ExecutionPlanError("UNINSTALLED_OPERATION", key, "No installed public operation binding"); + return selected; +} + +/** Bound argument/state codec subset. A parsed type alone does not mean a runtime implements it. */ +function checkValue(type: ValueType, path: string, allowChange = false): void { + switch (type.kind) { + case "scalar": + case "object-ref": + return; + case "builtin": + if (type.name === "unit") return; + break; + case "optional": + case "list": + checkValue(type.value, path, allowChange); + return; + case "record": + for (const [name, field] of Object.entries(type.fields)) checkValue(field, `${path}.${name}`, allowChange); + return; + case "change": + if (allowChange) { + checkValue(type.value, path); + return; + } + break; + } + throw new ExecutionPlanError("UNSUPPORTED_PORTABLE_CODEC", path, `Portable execution does not support ${type.kind}`); +} + +/** Compile declarations into a closed dispatch/dependency graph before artifact selection. */ +export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan { + const workspace = structuredClone(input); + const issues = validateWorkspaceRevision(workspace); + if (issues.length) + throw new ExecutionPlanError( + "INVALID_WORKSPACE", + workspace.id, + issues.map((i) => `${i.path}: ${i.message}`).join("\n"), + ); + const interfaces = new Map(workspace.interfaceImports.map((i) => [i.revisionId, i])); + const packages = new Map(workspace.packageImports.map((p) => [p.revisionId, p])); + const attachments = [...workspace.sharedAttachments, ...workspace.conformances.flatMap((c) => c.privateAttachments)]; + for (const pkg of workspace.packageImports) + for (const entry of pkg.exports) + if (entry.execution && entry.execution.kind !== "remote") { + checkValue(entry.inputType, `${pkg.revisionId}/${entry.id}/input`); + checkValue(entry.outputType, `${pkg.revisionId}/${entry.id}/output`); + } + for (const attachment of attachments) + if (attachment.kind === "state" && attachment.storagePolicy.kind === "crdt-document") + checkValue(attachment.valueType, attachment.id); + const operationMap = new Map(); + const queries = linkQueries(workspace); + const queryKey = (pkg: string, id: string) => JSON.stringify([pkg, id]); + const queryMap = new Map(queries.map((q) => [queryKey(q.packageRevisionId, q.checked.declaration.id), q])); + const declaration = (iface: InterfaceRevisionId, operation: OperationId) => + interfaces + .get(iface) + ?.members.flatMap((m) => m.operations) + .find((o) => o.id === operation); + const queryOperations = (query: LinkedQuery) => + query.fields.map((f) => publicOperationKey(f.atomId, f.interfaceRevisionId, f.getter)); + const targetAtoms = (binding: DependencyBinding, atom: AtomId): AtomId[] => { + if (!("via" in binding) || !binding.via) return [atom]; + const edge = attachments.find((a) => a.id === binding.via!.edgeTypeId); + if (!edge || edge.kind !== "edge") throw new ExecutionPlanError("MISSING_EDGE", atom, "Missing traversal edge"); + const endpoint = edge.endpoints.find((e) => e.projectionId !== binding.via!.projectionId)!; + const constraint = endpoint.constraint; + return workspace.atoms + .filter((a) => + constraint.kind === "atom" + ? constraint.atomId === a.id + : workspace.conformances.some( + (c) => c.atomId === a.id && c.interfaceRevisionId === constraint.interfaceRevisionId, + ), + ) + .map((a) => a.id); + }; + const add = (atom: AtomId, iface: InterfaceRevisionId, operation: OperationId): PlannedOperation => { + const key = publicOperationKey(atom, iface, operation), + existing = operationMap.get(key); + if (existing) return existing; + const publicContract = declaration(iface, operation); + if (!publicContract) throw new ExecutionPlanError("MISSING_OPERATION", key, "Missing public contract"); + const { binding } = resolveForwardedBinding(workspace, atom, iface, operation); + const planned: PlannedOperation = { + key, + atomId: atom, + interfaceRevisionId: iface, + operationId: operation, + scope: publicContract.scope === "class" ? "class" : "object", + execution: publicContract.execution ?? { kind: "remote" }, + input: publicContract.inputType, + output: publicContract.outputType, + binding, + dependencies: [], + }; + operationMap.set(key, planned); + const portable = planned.execution.kind !== "remote"; + if (portable) { + checkValue(planned.input, `${key}/input`, binding.kind === "state" && binding.primitive === "edit"); + checkValue(planned.output, `${key}/output`); + } + if (binding.kind === "state") planned.dependencies.push({ kind: "state", slot: binding.slotId }); + else if (binding.kind === "edge") + planned.dependencies.push({ kind: "edge", edge: binding.edgeTypeId, projection: binding.projectionId }); + else { + const entry = packages.get(binding.packageRevisionId)?.exports.find((e) => e.id === binding.exportId); + if (!entry) throw new ExecutionPlanError("MISSING_EXPORT", key, "Unresolved package export"); + for (const dependency of binding.dependencies) { + const port = entry.dependencyPorts.find((p) => p.id === dependency.portId)!; + const target = dependency.binding; + const via = "via" in target && target.via ? { via: target.via } : {}; + if ("via" in target && target.via) + planned.dependencies.push({ kind: "edge", edge: target.via.edgeTypeId, projection: target.via.projectionId }); + if (target.kind === "state") planned.dependencies.push({ kind: "state", slot: target.slotId, ...via }); + else if (target.kind === "edge") + planned.dependencies.push({ kind: "edge", edge: target.edgeTypeId, projection: target.projectionId, ...via }); + else if (target.kind === "interface" && port.requirement.kind === "interface") { + const selected = + port.requirement.operations ?? + interfaces.get(target.interfaceRevisionId)!.members.flatMap((m) => m.operations.map((o) => o.id)); + for (const targetAtom of targetAtoms(target, atom)) + for (const selectedOperation of selected) { + const operation = add(targetAtom, target.interfaceRevisionId, selectedOperation); + planned.dependencies.push({ kind: "operation", operation: operation.key, port: port.id, ...via }); + } + } else if (target.kind === "query") { + const query = queryMap.get(queryKey(target.packageRevisionId, target.queryId)); + if (!query) throw new ExecutionPlanError("MISSING_QUERY", key, `Unresolved query port ${port.id}`); + planned.dependencies.push({ + kind: "query", + query: queryKey(target.packageRevisionId, target.queryId), + port: port.id, + }); + } + } + } + planned.dependencies = sorted(planned.dependencies, canonicalJson); + return planned; + }; + for (const conformance of workspace.conformances) + for (const entry of conformance.operationBindings) + add(conformance.atomId, conformance.interfaceRevisionId, entry.operationId); + // Check the transitive closure, retaining a dependency path for diagnostics. + for (const root of operationMap.values()) { + if (root.execution.kind === "remote") continue; + const requested = root.execution, + visited = new Set(); + const walk = (key: string, path: string[]) => { + if (visited.has(key)) return; + visited.add(key); + const node = operationMap.get(key)!; + if (node.execution.kind === "remote" || (requested.kind === "snapshot" && node.execution.kind !== "snapshot")) + throw new ExecutionPlanError( + "NONPORTABLE_DEPENDENCY", + path.join(" -> "), + "Execution effect is not allowed by the root contract", + ); + for (const target of requested.targets) + if (!node.execution.targets.includes(target)) + throw new ExecutionPlanError( + "MISSING_EXECUTION_TARGET", + path.join(" -> "), + `Dependency does not provide ${target}`, + ); + for (const dependency of node.dependencies) { + if (dependency.kind === "operation") + walk(dependency.operation, [...path, dependency.port, dependency.operation]); + if (dependency.kind === "query") { + // An admitted presentation window is not an authoritative mutation read set. + if (requested.kind === "transaction") + throw new ExecutionPlanError( + "QUERY_IN_TRANSACTION", + [...path, dependency.port].join(" -> "), + "Capture explicit object references as mutation inputs; local presentation queries cannot define transaction membership", + ); + const query = queryMap.get(dependency.query)!; + if (query.checked.declaration.placement?.kind !== "local") + throw new ExecutionPlanError( + "REMOTE_QUERY_DEPENDENCY", + path.join(" -> "), + "Portable code requires a local query", + ); + for (const operation of queryOperations(query)) walk(operation, [...path, dependency.port, operation]); + } + } + }; + walk(root.key, [root.key]); + } + const plannedQueries: PlannedQuery[] = queries.map((query) => ({ + id: queryKey(query.packageRevisionId, query.checked.declaration.id), + query, + operations: queryOperations(query).sort(), + admittedOperations: [], + })); + for (const query of plannedQueries) { + const placement = query.query.checked.declaration.placement; + if (placement?.kind !== "local") continue; + const source = plannedQueries.find( + (q) => q.id === queryKey(query.query.packageRevisionId, placement.sourceQueryId), + ); + if (!source || source.query.checked.declaration.placement?.kind !== "replica") + throw new ExecutionPlanError("MISSING_REPLICA_SOURCE", query.id, "Local query source must be a replica query"); + const localRoot = query.query.checked.declaration.root, + sourceRoot = source.query.checked.declaration.root; + if (localRoot.kind !== sourceRoot.kind || localRoot.interface !== sourceRoot.interface) + throw new ExecutionPlanError( + "REPLICA_ROOT_MISMATCH", + query.id, + "Local and replica queries must use the same root contract", + ); + const traversals = (linked: LinkedQuery) => { + const paths = new Set(); + const visit = (selections: LinkedQuery["checked"]["selection"], parents: string[]) => { + for (const selection of selections) { + const member = selection.interfaceRevisionId + ? interfaces.get(selection.interfaceRevisionId)?.members.find((m) => m.id === selection.memberId) + : undefined; + const path = member?.kind === "relationship" ? [...parents, member.id] : parents; + if (member?.kind === "relationship") paths.add(JSON.stringify(path)); + visit(selection.selection, path); + } + }; + visit(linked.checked.selection, []); + return paths; + }; + const admittedTraversals = traversals(source.query); + for (const traversal of traversals(query.query)) + if (!admittedTraversals.has(traversal)) + throw new ExecutionPlanError( + "REPLICA_TRAVERSAL_MISSING", + query.id, + `Replica source does not admit relationship path ${traversal}`, + ); + // The admission executor follows these canonical dependencies on the admitted roots. + // They do not change source candidate membership or make local take/filters authoritative. + source.admittedOperations.push(...query.operations); + for (const operation of query.operations) { + const node = operationMap.get(operation)!; + if (node.execution.kind !== "snapshot" || !node.execution.targets.includes("browser")) + throw new ExecutionPlanError( + "NONPORTABLE_QUERY_FIELD", + `${query.id}/${operation}`, + "Local query getter must provide browser snapshot execution", + ); + } + } + for (const query of plannedQueries) query.admittedOperations = [...new Set(query.admittedOperations)].sort(); + const schema: BindingSchema = { + format: "quixos-bindings", + version: 1, + interfaces: sorted( + workspace.interfaceImports.filter((i) => !i.template), + (i) => i.revisionId, + ), + interfaceTemplates: sorted( + workspace.interfaceImports.filter((i) => i.template), + (i) => i.revisionId, + ), + packages: sorted(workspace.packageImports, (p) => p.revisionId), + }; + const normalizedSchema = JSON.parse(canonicalJson(schema)) as BindingSchema; + const definition: ExecutionPlanDefinition = { + codec: "camino-execution-plan-v1", + workspace, + bindingSchema: normalizedSchema, + packages: sorted(workspace.packageImports, (p) => p.revisionId).map((p) => ({ + revision: p.revisionId, + source: p.source, + sourceDigest: hexDigest({ revision: p.revisionId, source: p.source }), + bindingDigest: hexDigest(normalizedSchema), + exports: sorted(p.exports, (e) => e.id).map((e) => ({ id: e.id, execution: e.execution ?? { kind: "remote" } })), + })), + owners: sorted(workspace.conformances, (c) => c.id ?? "").map((c) => { + if (!c.id) + throw new ExecutionPlanError( + "OWNER_ID_REQUIRED", + `${c.atomId}/${c.interfaceRevisionId}`, + "Installed state owners require explicit stable identities", + ); + return { + id: c.id, + atom: c.atomId, + interface: c.interfaceRevisionId, + initialization: c.initialization ?? [], + storage: c.privateAttachments, + }; + }), + operations: sorted([...operationMap.values()], (o) => o.key), + queries: sorted(plannedQueries, (q) => q.id), + profiles: sorted( + attachments.filter((a) => a.kind === "state" && a.storagePolicy.kind === "crdt-document"), + (a) => a.id, + ).map((a) => ({ slot: a.id, profile: "camino-automerge-3.4.1-structured-v1" })), + versions: { ...executionVersions }, + }; + const normalized = JSON.parse(canonicalJson(definition)) as ExecutionPlanDefinition; + return { definition: normalized, digest: hexDigest(normalized) }; +} diff --git a/src/execution-world/ports.ts b/src/execution-world/ports.ts new file mode 100644 index 0000000..666e102 --- /dev/null +++ b/src/execution-world/ports.ts @@ -0,0 +1,78 @@ +/** Process/module ABI shared by generated adapters. Values are decoded against the checked + * operation/port schema by the coordinator, not trusted because this header parsed. */ +export const transactionPortAbi = "quixos-transaction-ports-v1"; +export type PortAction = + | { kind: "state"; primitive: "read" | "write" | "edit" } + | { kind: "edge"; primitive: "resolve" | "connect" | "disconnect" } + | { kind: "interface"; operationId: string; key?: string } + | { kind: "query" } + | { kind: "creation"; key: string }; +export interface PortInvocation { + abi: typeof transactionPortAbi; + /** Coordinator-issued frame and invocation handles, not application object IDs. */ + frame: string; + invocation: string; + sequence: number; + portId: string; + action: PortAction; + input: unknown; +} +export interface PackageInvocation { + abi: typeof transactionPortAbi; + frame: string; + invocation: string; + exportId: string; + effect: "snapshot" | "transaction" | "remote"; + /** Capability arguments/receiver use the versioned managed-reference codec. */ + input: unknown; + receiver: unknown | null; +} +export type InvocationReply = + | { abi: typeof transactionPortAbi; invocation: string; status: "returned"; output: unknown } + | { abi: typeof transactionPortAbi; invocation: string; status: "failed"; error: { code: string; message: string } }; +export type ServiceControl = + | { abi: typeof transactionPortAbi; kind: "ready" } + | { abi: typeof transactionPortAbi; kind: "cancel"; invocation: string } + | { abi: typeof transactionPortAbi; kind: "shutdown" }; +/** ABI-only header check. LF-08 resolves handles and enforces the frame/port authority. */ +export function checkPortInvocation(value: unknown): PortInvocation { + if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) + throw new Error("Invalid port invocation"); + const fields = value as Record; + const keys = ["abi", "frame", "invocation", "sequence", "portId", "action", "input"]; + if ( + Object.keys(fields).length !== keys.length || + keys.some((key) => !Object.hasOwn(fields, key)) || + fields.abi !== transactionPortAbi + ) + throw new Error("Invalid port invocation fields or ABI"); + for (const key of ["frame", "invocation", "portId"]) + if (typeof fields[key] !== "string" || !fields[key] || fields[key].length > 4096) + throw new Error(`Invalid port invocation ${key}`); + if (!Number.isSafeInteger(fields.sequence) || (fields.sequence as number) < 1) + throw new Error("Invalid port invocation sequence"); + const action = fields.action as Record; + if ( + !action || + typeof action !== "object" || + Array.isArray(action) || + Object.getPrototypeOf(action) !== Object.prototype + ) + throw new Error("Invalid port action"); + const exact = (names: string[]) => + Object.keys(action).length === names.length && names.every((name) => Object.hasOwn(action, name)); + const identifier = (value: unknown) => typeof value === "string" && value.length > 0 && value.length <= 1024; + const valid = + action.kind === "state" + ? exact(["kind", "primitive"]) && ["read", "write", "edit"].includes(action.primitive as string) + : action.kind === "edge" + ? exact(["kind", "primitive"]) && ["resolve", "connect", "disconnect"].includes(action.primitive as string) + : action.kind === "interface" + ? (exact(["kind", "operationId"]) || (exact(["kind", "operationId", "key"]) && identifier(action.key))) && + identifier(action.operationId) + : action.kind === "creation" + ? exact(["kind", "key"]) && identifier(action.key) + : action.kind === "query" && exact(["kind"]); + if (!valid) throw new Error("Invalid port action"); + return { ...fields, action: { ...action } } as unknown as PortInvocation; +} diff --git a/test/execution-world.test.ts b/test/execution-world.test.ts new file mode 100644 index 0000000..2e2568d --- /dev/null +++ b/test/execution-world.test.ts @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createHash } from "node:crypto"; +import { loadReaderWorkspace } from "./reader-fixture.js"; +import { + compileExecutionPlan, + checkExecutionWorld, + checkedWorldDigest, + implementationDigest, + hexDigest, + parsePackageArtifacts, + artifactPath, + type PackageArtifacts, + type ExecutionPlan, + type ArtifactImplementation, +} from "../src/execution-world/index.js"; + +const buildFixture = (plan: ExecutionPlan) => { + const expected = plan.definition.packages[0]!; + const bytes = new TextEncoder().encode("export const handlers = Object.freeze({});\n"); + const implementation = ( + id: string, + target: string, + compatibility: ArtifactImplementation["compatibility"], + ): ArtifactImplementation => { + const path = compatibility === "quixos-service-v1" ? "bin/service" : `portable/${target}/module.mjs`; + const value = { + id, + target, + compatibility, + path, + ports: "quixos-transaction-ports-v1" as const, + ...(compatibility === "quixos-js-module-v1" ? { registryExport: "handlers" } : {}), + exports: expected.exports.map((e) => e.id), + files: [ + { + path, + digest: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + mediaType: compatibility === "quixos-service-v1" ? "application/octet-stream" : "text/javascript", + }, + ], + }; + return { ...value, contentDigest: implementationDigest(value) }; + }; + const manifest: PackageArtifacts = { + schemaVersion: 1, + packageRevisionId: expected.revision, + bindingDigest: expected.bindingDigest, + sourceDigest: expected.sourceDigest, + versions: plan.definition.versions, + implementations: [ + implementation("service", "server", "quixos-service-v1"), + implementation("server", "server", "quixos-js-module-v1"), + implementation("browser", "browser", "quixos-js-module-v1"), + ], + }; + return { manifest, bytes, read: async () => bytes }; +}; +const resign = (implementation: ArtifactImplementation) => { + const { contentDigest, ...body } = implementation; + implementation.contentDigest = implementationDigest(body); +}; + +test("reader execution plan closes selector storage and traversed interface dependencies", async () => { + const workspace = await loadReaderWorkspace(); + const plan = compileExecutionPlan(workspace); + assert.equal(plan.digest, compileExecutionPlan(structuredClone(workspace)).digest); + assert.equal(plan.definition.owners.length, 5); + const excerpt = + plan.definition.operations.find((o) => o.operationId === "highlight:excerpt:get") ?? + plan.definition.operations.find((o) => o.binding.kind === "package" && o.binding.exportId === "reader:excerpt")!; + assert.ok(excerpt.dependencies.some((d) => d.kind === "state" && d.slot === "slot:highlight:range")); + assert.ok( + excerpt.dependencies.some( + (d) => d.kind === "edge" && d.edge === "edge:document-highlights" && d.projection === "projection:document", + ), + ); + const calls = excerpt.dependencies.filter((d) => d.kind === "operation"); + assert.equal(calls.length, 2); + for (const call of calls) { + assert.equal(call.via?.projectionId, "projection:document"); + const target = plan.definition.operations.find((o) => o.key === call.operation)!; + assert.ok(target.dependencies.some((d) => d.kind === "state" && d.slot.startsWith("slot:document:"))); + } + const admission = plan.definition.queries.find((q) => q.query.checked.declaration.displayName === "ReplicaInputs")!; + assert.ok(admission.admittedOperations.includes(excerpt.key)); + assert.equal(plan.definition.profiles[0]!.profile, "camino-automerge-3.4.1-structured-v1"); + const factory = plan.definition.operations.find( + (o) => o.binding.kind === "package" && o.binding.exportId === "reader:create-document", + )!; + assert.equal(factory.scope, "class"); + assert.ok( + factory.binding.kind === "package" && factory.binding.dependencies.some((d) => d.binding.kind === "creation"), + ); + assert.ok(plan.definition.owners.some((o) => Array.isArray(o.initialization) && o.initialization.length > 0)); +}); + +test("artifact closure checks bytes, exact source/schema, ABI, exports and target coverage", async () => { + const plan = compileExecutionPlan(await loadReaderWorkspace()); + const fixture = buildFixture(plan); + const world = await checkExecutionWorld(plan, [fixture.manifest], fixture.read); + assert.equal(world.artifacts.length, 3); + assert.equal( + checkedWorldDigest(world), + checkedWorldDigest(await checkExecutionWorld(plan, [structuredClone(fixture.manifest)], fixture.read)), + ); + const negative = async (change: (manifest: PackageArtifacts) => void, pattern: RegExp) => { + const manifest = structuredClone(fixture.manifest); + change(manifest); + await assert.rejects(checkExecutionWorld(plan, [manifest], fixture.read), pattern); + }; + await negative((m) => { + m.bindingDigest = "0".repeat(64); + }, /Substituted binding schema/); + await negative((m) => { + m.sourceDigest = "0".repeat(64); + }, /source revision/); + await negative((m) => { + (m.versions as unknown as Record).value = "v2"; + }, /Incompatible runtime codecs/); + await negative((m) => { + m.implementations = m.implementations.filter((i) => i.target !== "browser"); + }, /Missing browser implementation/); + await negative((m) => { + m.implementations = m.implementations.filter((i) => i.compatibility !== "quixos-service-v1"); + }, /Missing service fallback/); + await negative((m) => { + m.implementations[0]!.exports.push("wrong-export"); + resign(m.implementations[0]!); + }, /Unknown export/); + await negative((m) => { + m.implementations[0]!.files[0]!.digest = "0".repeat(64); + resign(m.implementations[0]!); + }, /Artifact bytes/); + await negative((m) => { + m.implementations[0]!.exports.pop(); + }, /metadata digest mismatch/); + await assert.rejects( + checkExecutionWorld(plan, [fixture.manifest], async () => new Uint8Array([1])), + /Artifact bytes/, + ); + for (const path of ["/tmp/code", "../code", "a/../b", "a//b", "a\\b", "a%2fb", "a?query"]) + assert.throws(() => artifactPath(path), /normalized relative/); + const changed = structuredClone(plan); + changed.definition.versions.value = "wrong" as typeof changed.definition.versions.value; + await assert.rejects( + checkExecutionWorld(changed, [fixture.manifest], fixture.read), + /Unsupported plan codec versions/, + ); +}); + +test("artifact identity changes when code or registry changes without hashing its own digest", async () => { + const plan = compileExecutionPlan(await loadReaderWorkspace()), + fixture = buildFixture(plan); + const before = checkedWorldDigest(await checkExecutionWorld(plan, [fixture.manifest], fixture.read)); + const manifest = structuredClone(fixture.manifest); + manifest.implementations[2]!.registryExport = "anotherRegistry"; + resign(manifest.implementations[2]!); + const after = checkedWorldDigest(await checkExecutionWorld(plan, [manifest], fixture.read)); + assert.notEqual(before, after); + assert.equal(parsePackageArtifacts(manifest).implementations[2]!.registryExport, "anotherRegistry"); + assert.equal(hexDigest(plan.definition), plan.digest); +}); + +test("transactions cannot derive their membership from an admitted presentation query", async () => { + const workspace = await loadReaderWorkspace(), + pkg = workspace.packageImports[0]!; + const query = pkg.queries!.find((q) => q.placement?.kind === "local")!; + const entry = pkg.exports.find((e) => e.id === "reader:add-highlight")!; + const portId = "test:query-port" as (typeof entry.dependencyPorts)[number]["id"]; + entry.dependencyPorts.push({ + id: portId, + displayName: "visible", + requirement: { kind: "query", packageRevisionId: pkg.revisionId, queryId: query.id }, + }); + const bound = workspace.conformances + .flatMap((c) => c.operationBindings) + .find((o) => o.binding.kind === "package" && o.binding.exportId === entry.id)!.binding; + assert.equal(bound.kind, "package"); + if (bound.kind !== "package") return; + bound.dependencies.push({ + portId, + binding: { kind: "query", packageRevisionId: pkg.revisionId, queryId: query.id, scope: "object" }, + }); + assert.throws(() => compileExecutionPlan(workspace), /QUERY_IN_TRANSACTION.*test:query-port/); +}); + +test("port ABI carries only declared port actions, never caller-selected storage IDs", async () => { + const { checkPortInvocation } = await import("../src/execution-world/index.js"); + const frame = { + abi: "quixos-transaction-ports-v1", + frame: "frame1", + invocation: "invoke1", + sequence: 1, + portId: "content", + action: { kind: "state", primitive: "read" }, + input: { codec: "camino-value-v1", usage: "argument", value: null }, + }; + assert.deepEqual(checkPortInvocation(frame), frame); + assert.throws(() => checkPortInvocation({ ...frame, slotId: "private" }), /fields or ABI/); + assert.throws( + () => checkPortInvocation({ ...frame, action: { kind: "state", primitive: "read", slotId: "private" } }), + /Invalid port action/, + ); + assert.throws(() => checkPortInvocation({ ...frame, sequence: NaN }), /sequence/); + assert.throws(() => checkPortInvocation({ ...frame, action: { kind: "creation", key: "" } }), /Invalid port action/); +}); + +test("local queries cannot silently expand the replica relationship traversal", async () => { + const workspace = await loadReaderWorkspace((name, text) => + name === "queries/replica-inputs.graphql" ? "query ReplicaInputs { root { sourceRevision content } }" : text, + ); + assert.throws(() => compileExecutionPlan(workspace), /REPLICA_TRAVERSAL_MISSING/); +}); + +test("closed generic applications retain distinct identities and codec shapes", async () => { + const { compileCapabilityResourceSource, compileCapabilitySource } = + await import("../src/capability-language/index.js"); + const source = { repository: "https://example.test/generic.git", commit: "a".repeat(40) }; + const definition = compileCapabilityResourceSource( + `interface Cell id "cell" revision "cell@1" { + value content id "value" : V { get id "get" execution snapshot targets [browser, server]; } + }`, + { source }, + ); + assert.ok(definition.ok && definition.resource.kind === "interface", JSON.stringify(definition.diagnostics)); + const compiled = compileCapabilitySource( + `workspace Demo id "ws" revision "ws@1" commit "${source.commit}" { + import interface Cell; atom Item id "item"; + conform Item as Cell id "text-owner" { private state Text id "text-slot" on Item : string policy register; bind content.get to state Text.read; } + conform Item as Cell id "number-owner" { private state Number id "number-slot" on Item : int64 policy register; bind content.get to state Number.read; } + }`, + "workspace.qx", + { interfaces: new Map([["Cell", definition.resource.revision]]) }, + ); + assert.ok(compiled.ok, JSON.stringify(compiled.diagnostics)); + const plan = compileExecutionPlan(compiled.workspace); + assert.equal(plan.definition.operations.length, 2); + assert.notEqual( + plan.definition.operations[0]!.interfaceRevisionId, + plan.definition.operations[1]!.interfaceRevisionId, + ); + assert.deepEqual( + new Set(plan.definition.operations.map((o) => (o.output.kind === "scalar" ? o.output.name : null))), + new Set(["string", "int64"]), + ); + assert.ok(plan.definition.bindingSchema.interfaces.every((i) => i.application)); +}); + +test("unbound portable exports cannot claim an unsupported transport codec", async () => { + const workspace = await loadReaderWorkspace(); + workspace.packageImports[0]!.exports.find((e) => e.id === "reader:validate-range")!.outputType = { + kind: "message", + descriptorId: "opaque-message", + }; + assert.throws(() => compileExecutionPlan(workspace), /UNSUPPORTED_PORTABLE_CODEC.*reader:validate-range/); +}); + +test("query dependencies propagate target requirements through selected getters", async () => { + const workspace = await loadReaderWorkspace(), + pkg = workspace.packageImports[0]!; + const query = pkg.queries!.find((q) => q.displayName === "HighlightList")!; + const entry = pkg.exports.find((e) => e.id === "reader:excerpt")!; + const portId = "test:selector-query" as (typeof entry.dependencyPorts)[number]["id"]; + entry.dependencyPorts.push({ + id: portId, + displayName: "visible", + requirement: { kind: "query", packageRevisionId: pkg.revisionId, queryId: query.id }, + }); + const bound = workspace.conformances + .flatMap((c) => c.operationBindings) + .find((o) => o.binding.kind === "package" && o.binding.exportId === entry.id)!.binding; + assert.equal(bound.kind, "package"); + if (bound.kind !== "package") return; + bound.dependencies.push({ + portId, + binding: { kind: "query", packageRevisionId: pkg.revisionId, queryId: query.id, scope: "workspace" }, + }); + compileExecutionPlan(workspace); + const field = workspace.interfaceImports + .find((i) => i.displayName === "HighlightData")! + .members.find((m) => m.displayName === "color")!; + field.operations.find((o) => o.displayName === "get")!.execution = { kind: "snapshot", targets: ["browser"] }; + assert.throws(() => compileExecutionPlan(workspace), /MISSING_EXECUTION_TARGET.*test:selector-query.*server/); +}); diff --git a/test/local-first-reader.test.ts b/test/local-first-reader.test.ts index cc6ffcd..7ac3243 100644 --- a/test/local-first-reader.test.ts +++ b/test/local-first-reader.test.ts @@ -1,44 +1,8 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { - compileCapabilityResourceSource, - compileCapabilitySource, - formatQx, -} from "../src/capability-language/index.js"; -import type { InterfaceRevision } from "../src/capability-model/types.js"; import { compileQuery } from "../src/query/compile.js"; import { linkQueries } from "../src/query/link.js"; -const directory = resolve("test/fixtures/local-first-reader"); -const read = (name: string) => readFile(resolve(directory, name), "utf8"); -const source = { repository: "https://example.test/reader.git", commit: "a".repeat(40) }; -const load = async (transform = (name: string, text: string) => text) => { - const interfaces = new Map(); - for (const name of ["DocumentData", "HighlightData", "CommentData", "DocumentActions", "DocumentFactory"]) { - const text = transform(`${name}.qx`, await read(`${name}.qx`)); - const result = compileCapabilityResourceSource(formatQx(text), { source }); - assert.ok(result.ok && result.resource.kind === "interface", `${name}: ${JSON.stringify(result.diagnostics)}`); - interfaces.set(name, result.resource.revision); - } - const environment = { interfaces, interfaceClosure: [...interfaces.values()] }; - const pkg = compileCapabilityResourceSource(formatQx(transform("ReaderRuntime.qx", await read("ReaderRuntime.qx"))), { - source, - environment, - }); - assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics)); - const revision = pkg.resource.revision; - revision.checkedQueries = await Promise.all( - revision.queries!.map((query) => compileQuery(query, [...interfaces.values()], read)), - ); - const result = compileCapabilitySource(formatQx(await read("workspace.qx")), "workspace.qx", { - ...environment, - packages: new Map([["ReaderRuntime", revision]]), - packageClosure: [revision], - }); - assert.ok(result.ok, JSON.stringify(result.diagnostics)); - return result.workspace; -}; +import { loadReaderWorkspace as load, readReaderFixture as read } from "./reader-fixture.js"; test("complete reader contracts compose factories, portable selectors, CRDT edits, and six query scopes", async () => { const workspace = await load(); assert.equal(workspace.constructors.length, 0); diff --git a/test/reader-fixture.ts b/test/reader-fixture.ts new file mode 100644 index 0000000..7664e55 --- /dev/null +++ b/test/reader-fixture.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + compileCapabilityResourceSource, + compileCapabilitySource, + formatQx, +} from "../src/capability-language/index.js"; +import type { InterfaceRevision } from "../src/capability-model/types.js"; +import { compileQuery } from "../src/query/compile.js"; +const directory = resolve("test/fixtures/local-first-reader"); +export const readReaderFixture = (name: string) => readFile(resolve(directory, name), "utf8"); +const source = { repository: "https://example.test/reader.git", commit: "a".repeat(40) }; +export const loadReaderWorkspace = async (transform = (name: string, text: string) => text) => { + const interfaces = new Map(); + for (const name of ["DocumentData", "HighlightData", "CommentData", "DocumentActions", "DocumentFactory"]) { + const text = transform(`${name}.qx`, await readReaderFixture(`${name}.qx`)); + const result = compileCapabilityResourceSource(formatQx(text), { source }); + assert.ok(result.ok && result.resource.kind === "interface", `${name}: ${JSON.stringify(result.diagnostics)}`); + interfaces.set(name, result.resource.revision); + } + const environment = { interfaces, interfaceClosure: [...interfaces.values()] }; + const pkg = compileCapabilityResourceSource( + formatQx(transform("ReaderRuntime.qx", await readReaderFixture("ReaderRuntime.qx"))), + { + source, + environment, + }, + ); + assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics)); + const revision = pkg.resource.revision; + revision.checkedQueries = await Promise.all( + revision.queries!.map((query) => + compileQuery(query, [...interfaces.values()], async (name) => transform(name, await readReaderFixture(name))), + ), + ); + const result = compileCapabilitySource(formatQx(await readReaderFixture("workspace.qx")), "workspace.qx", { + ...environment, + packages: new Map([["ReaderRuntime", revision]]), + packageClosure: [revision], + }); + assert.ok(result.ok, JSON.stringify(result.diagnostics)); + return result.workspace; +};