Implement checked workspace integration contracts (WI-01)

This commit is contained in:
Timothy J. Aveni
2026-09-21 11:53:38 -07:00
parent 3c0378358f
commit 884b12f759
22 changed files with 4349 additions and 3089 deletions
+86
View File
@@ -0,0 +1,86 @@
import type { InterfaceRevision, PackageController, WorkspaceRevision } from "./types.js";
import type { CapabilityValidationIssue } from "./validation.js";
import { canonicalJson } from "./evolution.js";
export function validateControllers(
controllers: readonly PackageController[],
interfaces: readonly InterfaceRevision[],
prefix: string,
): CapabilityValidationIssue[] {
const issues: CapabilityValidationIssue[] = [],
ids = new Set<string>();
const fail = (path: string, message: string) => issues.push({ code: "invalid-controller" as const, path, message });
for (const [index, controller] of controllers.entries()) {
const path = `${prefix}[${index}]`;
if (!controller.id.trim() || ids.has(controller.id))
fail(`${path}.id`, "Controller identities must be nonempty and unique within the package");
ids.add(controller.id);
const iface = interfaces.find((i) => i.revisionId === controller.subject && !i.template);
if (!iface) {
fail(`${path}.subject`, "Controller subject must be an imported closed interface");
continue;
}
const operations = iface.members.flatMap((m) => m.operations);
const snapshot = operations.find((o) => o.id === controller.snapshot),
remote = operations.find((o) => o.id === controller.remote);
const unit = (value: unknown) => canonicalJson(value) === canonicalJson({ kind: "builtin", name: "unit" });
if (
!snapshot ||
snapshot.scope === "class" ||
snapshot.mode !== "call" ||
snapshot.execution?.kind !== "snapshot" ||
!snapshot.execution.targets.includes("server") ||
!unit(snapshot.inputType)
)
fail(`${path}.snapshot`, "Controller snapshot must be a server snapshot object call with unit input");
if (
!remote ||
remote.scope === "class" ||
remote.mode !== "call" ||
(remote.execution?.kind ?? "remote") !== "remote"
)
fail(`${path}.remote`, "Controller target must be a remote object call");
if (controller.input !== "unit" && controller.input !== "value")
fail(`${path}.input`, "Controller input must be unit or value");
else if (
remote &&
(controller.input === "unit"
? !unit(remote.inputType)
: snapshot && canonicalJson(snapshot.outputType) !== canonicalJson(remote.inputType))
)
fail(`${path}.input`, "Controller input mapping does not match the remote operation input");
}
return issues;
}
export function validateWorkspaceControllers(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
return workspace.packageImports.flatMap((pkg, index) =>
validateControllers(pkg.controllers ?? [], workspace.interfaceImports, `packageImports[${index}].controllers`),
);
}
/** The file is declarative metadata, not an executable registration callback. */
export function parseControllerCatalog(text: string, interfaces: readonly InterfaceRevision[]): PackageController[] {
if (Buffer.byteLength(text) > 1048576) throw new Error("Controller catalog exceeds 1 MiB");
const value: unknown = JSON.parse(text);
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
if (
!record(value) ||
value.schemaVersion !== 1 ||
Object.keys(value).some((k) => !["schemaVersion", "controllers"].includes(k)) ||
!Array.isArray(value.controllers) ||
value.controllers.length > 1000
)
throw new Error("Invalid controller catalog: expected schemaVersion 1 and at most 1000 controllers");
const fields = ["id", "subject", "snapshot", "remote", "input"];
for (const entry of value.controllers)
if (
!record(entry) ||
Object.keys(entry).length !== fields.length ||
fields.some((key) => typeof entry[key] !== "string")
)
throw new Error("Invalid controller entry: expected exactly id, subject, snapshot, remote, input strings");
const controllers = value.controllers as PackageController[];
const issues = validateControllers(controllers, interfaces, "controllers");
if (issues.length) throw new Error(issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
return controllers;
}
+56
View File
@@ -0,0 +1,56 @@
import type { WorkspaceRevision } from "./types.js";
import type { CapabilityValidationIssue } from "./validation.js";
/** Presentation metadata has no graph authority and cannot name arbitrary module URLs. */
export function validatePresentations(workspace: WorkspaceRevision): CapabilityValidationIssue[] {
const issues: CapabilityValidationIssue[] = [];
const fail = (path: string, message: string) => issues.push({ code: "invalid-presentation", path, message });
const interfaces = new Map(workspace.interfaceImports.map((entry) => [entry.revisionId, entry]));
const packages = new Map(workspace.packageImports.map((entry) => [entry.revisionId, entry]));
const text = (value: string, path: string) => {
if (!value.trim()) fail(path, "Presentation identities and names must be nonempty");
};
const subject = (id: string, path: string) => {
const contract = interfaces.get(id as WorkspaceRevision["interfaceImports"][number]["revisionId"]);
if (!contract || contract.template) fail(path, `Expected an imported closed subject interface: ${id}`);
};
for (const [index, pkg] of workspace.packageImports.entries()) {
const ids = new Set(pkg.exports.map((entry) => entry.id as string));
const names = new Set([
...pkg.exports.map((entry) => entry.displayName),
...(pkg.queries ?? []).map((entry) => entry.displayName),
...(pkg.queryTemplates ?? []).map((entry) => entry.declaration.displayName),
...(pkg.genericExports ?? []).map((entry) => entry.displayName),
]);
for (const [componentIndex, component] of (pkg.components ?? []).entries()) {
const path = `packageImports[${index}].components[${componentIndex}]`;
text(component.id, `${path}.id`);
text(component.displayName, `${path}.displayName`);
if (ids.has(component.id) || names.has(component.displayName))
fail(path, "Duplicate component identity or package member name");
ids.add(component.id);
names.add(component.displayName);
subject(component.subject, `${path}.subject`);
if (component.runtime !== "react") fail(`${path}.runtime`, "Unsupported component runtime");
}
}
const ids = new Set<string>(),
names = new Set<string>();
for (const [index, presentation] of (workspace.presentations ?? []).entries()) {
const path = `presentations[${index}]`;
text(presentation.id, `${path}.id`);
text(presentation.displayName, `${path}.displayName`);
if (ids.has(presentation.id) || names.has(presentation.displayName))
fail(path, "Duplicate presentation identity or name");
ids.add(presentation.id);
names.add(presentation.displayName);
subject(presentation.subject, `${path}.subject`);
const component = packages
.get(presentation.packageRevisionId)
?.components?.find((entry) => entry.id === presentation.componentId);
if (!component) fail(`${path}.componentId`, "Presentation names no installed package component");
else if (component.subject !== presentation.subject)
fail(`${path}.subject`, `Component requires ${component.subject}, not ${presentation.subject}`);
}
return issues;
}
+28
View File
@@ -288,7 +288,25 @@ export interface PackageConstructorExport extends PackageExportBase {
export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport;
/** A window presentation entry, never a graph operation or service export. */
export interface PackageComponent {
id: string;
displayName: string;
subject: InterfaceRevisionId;
runtime: "react";
}
export interface WorkspacePresentation {
id: string;
displayName: string;
subject: InterfaceRevisionId;
packageRevisionId: PackageRevisionId;
componentId: string;
}
export interface PackageRevision {
controllers?: PackageController[];
components?: PackageComponent[];
queries?: import("../query/types.js").QueryDeclaration[];
checkedQueries?: import("../query/types.js").CheckedQuery[];
queryTemplates?: import("../query/types.js").QueryTemplate[];
@@ -304,6 +322,15 @@ export interface PackageRevision {
semanticMajor?: number;
}
/** Checked adapter registration. Implementations remain ordinary object capabilities. */
export interface PackageController {
id: string;
subject: InterfaceRevisionId;
snapshot: OperationId;
remote: OperationId;
input: "unit" | "value";
}
export type DependencyBinding =
| { kind: "reflection"; selectors: string[]; via?: never }
| {
@@ -415,6 +442,7 @@ export interface AtomConstructorBinding {
}
export interface WorkspaceRevision {
presentations?: WorkspacePresentation[];
linkedQueries?: import("../query/link.js").LinkedQuery[];
id: WorkspaceRevisionId;
workspaceId: WorkspaceId;
+10 -1
View File
@@ -1,5 +1,7 @@
import { isStorableType } from "./generics.js";
import { validateInitialization } from "./initialization.js";
import { validatePresentations } from "./presentations.js";
import { validateWorkspaceControllers } from "./controllers.js";
import { validateExecutionContracts } from "./execution.js";
import { resolveForwardedBinding, type ConcreteBinding } from "./forwarding.js";
import type {
@@ -44,6 +46,8 @@ import { specializePackageExport } from "./generic-packages.js";
import { isDeepStrictEqual } from "node:util";
export type CapabilityValidationIssueCode =
| "invalid-controller"
| "invalid-presentation"
| "invalid-initialization"
| "invalid-execution-contract"
| "invalid-query-contract"
@@ -2213,7 +2217,12 @@ export const validateWorkspaceRevision = (workspace: WorkspaceRevision): Capabil
validatePackages(workspace, issues, indexes);
validateConformances(workspace, issues, indexes);
validateConstructors(workspace, issues, indexes);
issues.push(...validateExecutionContracts(workspace), ...validateInitialization(workspace));
issues.push(
...validateExecutionContracts(workspace),
...validateInitialization(workspace),
...validatePresentations(workspace),
...validateWorkspaceControllers(workspace),
);
return issues;
};