Compile closed execution plans and checked artifact manifests
This commit is contained in:
@@ -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.
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
const fail = (path: string, message: string): never => {
|
||||
throw new ExecutionPlanError("INVALID_ARTIFACT", path, message);
|
||||
};
|
||||
const object = (value: unknown, path: string): Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype)
|
||||
fail(path, "Expected a plain JSON record");
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
const keys = (record: Record<string, unknown>, 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<ArtifactImplementation, "contentDigest">): 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<Uint8Array>;
|
||||
/** 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<CheckedWorld> {
|
||||
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<string, ArtifactFile>();
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./plan.js";
|
||||
export * from "./artifacts.js";
|
||||
export * from "./ports.js";
|
||||
@@ -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 = <T>(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<Binding, { kind: "interface" }>;
|
||||
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<string, PlannedOperation>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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) };
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user