From d824634859e1c83eaab4a5ead59d52bafbf09b7b Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Mon, 21 Sep 2026 17:38:45 -0700 Subject: [PATCH] Host checked adapter-object controllers through bounded registries (WI-13) --- src/capability-language/assembly.ts | 6 +- .../controller-scaffold.ts | 3 +- src/capability-language/scaffold-recipes.ts | 2 +- src/capability-model/controllers.ts | 50 ++++++++++++-- src/capability-model/types.ts | 2 + test/controller-registry.test.ts | 50 ++++++++++++++ test/emit-controllers.ts | 68 +++++++++++++++++++ test/emit-managed.ts | 2 + test/fixtures/controller-device/Device.qx | 11 +++ .../controller-device/DeviceController.qx | 6 ++ test/fixtures/controller-device/Driver.qx | 15 ++++ .../controller-device/enabled.graphql | 1 + .../fixtures/controller-device/inputs.graphql | 1 + test/fixtures/controller-device/workspace.qx | 25 +++++++ 14 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 test/controller-registry.test.ts create mode 100644 test/emit-controllers.ts create mode 100644 test/fixtures/controller-device/Device.qx create mode 100644 test/fixtures/controller-device/DeviceController.qx create mode 100644 test/fixtures/controller-device/Driver.qx create mode 100644 test/fixtures/controller-device/enabled.graphql create mode 100644 test/fixtures/controller-device/inputs.graphql create mode 100644 test/fixtures/controller-device/workspace.qx diff --git a/src/capability-language/assembly.ts b/src/capability-language/assembly.ts index 05c84eb..f55edab 100644 --- a/src/capability-language/assembly.ts +++ b/src/capability-language/assembly.ts @@ -257,7 +257,11 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } if (controllerText !== undefined) - compiled.resource.revision.controllers = parseControllerCatalog(controllerText, queryInterfaces); + compiled.resource.revision.controllers = parseControllerCatalog( + controllerText, + queryInterfaces, + compiled.resource.revision.checkedQueries, + ); let catalogText: string | undefined; try { catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8"); diff --git a/src/capability-language/controller-scaffold.ts b/src/capability-language/controller-scaffold.ts index d2e532f..f98df2a 100644 --- a/src/capability-language/controller-scaffold.ts +++ b/src/capability-language/controller-scaffold.ts @@ -10,7 +10,8 @@ export async function scaffoldController( const entry = { id: spec.id, ...spec.controller }; const fields = ["id", "subject", "snapshot", "remote", "input"]; if ( - Object.keys(entry).length !== fields.length || + Object.keys(entry).some((key) => ![...fields, "registry"].includes(key)) || + (entry.registry !== undefined && typeof entry.registry !== "string") || fields.some((key) => typeof (entry as Record)[key] !== "string") || !["unit", "value"].includes(entry.input ?? "") ) diff --git a/src/capability-language/scaffold-recipes.ts b/src/capability-language/scaffold-recipes.ts index a0a8f15..5906221 100644 --- a/src/capability-language/scaffold-recipes.ts +++ b/src/capability-language/scaffold-recipes.ts @@ -15,7 +15,7 @@ export type ScaffoldRecipe = { declaration?: string; queryDocument?: string; initialFiles?: Record; - controller?: { subject: string; snapshot: string; remote: string; input: "unit" | "value" }; + controller?: { registry?: string; subject: string; snapshot: string; remote: string; input: "unit" | "value" }; tools?: { quixos: Source; protocol: Source; helpers: Source; sdk?: Source }; }; const source = (value: Source): GitSource => { diff --git a/src/capability-model/controllers.ts b/src/capability-model/controllers.ts index 5cf9847..6fbc942 100644 --- a/src/capability-model/controllers.ts +++ b/src/capability-model/controllers.ts @@ -6,6 +6,7 @@ export function validateControllers( controllers: readonly PackageController[], interfaces: readonly InterfaceRevision[], prefix: string, + queries: readonly import("../query/types.js").CheckedQuery[] = [], ): CapabilityValidationIssue[] { const issues: CapabilityValidationIssue[] = [], ids = new Set(); @@ -15,6 +16,37 @@ export function validateControllers( 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); + if (controller.registry !== undefined) { + const query = queries.find((query) => query.declaration.id === controller.registry); + const selected = (entries: import("../query/types.js").QuerySelection[] | undefined, name: string) => + entries?.find((entry) => entry.name === name && entry.key === name && !entry.conditions.length); + const root = selected(query?.selection, "root"), + objects = selected(root?.selection, "objects"), + entries = selected(objects?.selection, "entries"), + node = selected(entries?.selection, "node"), + qx = selected(node?.selection, "_qx"), + ref = selected(qx?.selection, "ref"); + const all = objects?.arguments.all; + if ( + !query || + query.declaration.root.kind !== "workspace-interface" || + query.declaration.root.interface !== controller.subject || + query.declaration.placement?.kind === "local" || + query.effects.some((effect) => effect.execution === "rpc-permitted") || + query.variables.kind !== "record" || + Object.keys(query.variables.fields).length || + !ref || + all?.kind !== "literal" || + !/^\d+$/.test(String(all.value)) || + !Number.isSafeInteger(Number(all.value)) || + Number(all.value) < 1 || + Number(all.value) > 64 + ) + fail( + `${path}.registry`, + "Controller registry requires a native/portable workspace query over its subject, no variables, and root.objects(all: 1..64).entries.node._qx.ref without aliases or conditional selections", + ); + } const iface = interfaces.find((i) => i.revisionId === controller.subject && !i.template); if (!iface) { fail(`${path}.subject`, "Controller subject must be an imported closed interface"); @@ -54,11 +86,20 @@ export function validateControllers( } export function validateWorkspaceControllers(workspace: WorkspaceRevision): CapabilityValidationIssue[] { return workspace.packageImports.flatMap((pkg, index) => - validateControllers(pkg.controllers ?? [], workspace.interfaceImports, `packageImports[${index}].controllers`), + validateControllers( + pkg.controllers ?? [], + workspace.interfaceImports, + `packageImports[${index}].controllers`, + pkg.checkedQueries, + ), ); } /** The file is declarative metadata, not an executable registration callback. */ -export function parseControllerCatalog(text: string, interfaces: readonly InterfaceRevision[]): PackageController[] { +export function parseControllerCatalog( + text: string, + interfaces: readonly InterfaceRevision[], + queries: readonly import("../query/types.js").CheckedQuery[] = [], +): 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 => @@ -75,12 +116,13 @@ export function parseControllerCatalog(text: string, interfaces: readonly Interf for (const entry of value.controllers) if ( !record(entry) || - Object.keys(entry).length !== fields.length || + Object.keys(entry).some((key) => ![...fields, "registry"].includes(key)) || + (entry.registry !== undefined && typeof entry.registry !== "string") || 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"); + const issues = validateControllers(controllers, interfaces, "controllers", queries); if (issues.length) throw new Error(issues.map((i) => `${i.path}: ${i.message}`).join("\n")); return controllers; } diff --git a/src/capability-model/types.ts b/src/capability-model/types.ts index 14b53d0..3cf0f5d 100644 --- a/src/capability-model/types.ts +++ b/src/capability-model/types.ts @@ -326,6 +326,8 @@ export interface PackageRevision { /** Checked adapter registration. Implementations remain ordinary object capabilities. */ export interface PackageController { + /** Optional opt-in discovery: a bounded checked workspace query in this package. */ + registry?: string; id: string; subject: InterfaceRevisionId; snapshot: OperationId; diff --git a/test/controller-registry.test.ts b/test/controller-registry.test.ts new file mode 100644 index 0000000..a22ea98 --- /dev/null +++ b/test/controller-registry.test.ts @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { compileCapabilityResourceSource } from "../src/capability-language/index.js"; +import { compileQuery } from "../src/query/compile.js"; +import { parseControllerCatalog } from "../src/capability-model/controllers.js"; +const source = { repository: "https://example.test/device.git", commit: "a".repeat(40) }; +test("controller discovery requires complete bounded accepted-state membership", async () => { + const result = compileCapabilityResourceSource( + await readFile("test/fixtures/controller-device/DeviceController.qx", "utf8"), + { source }, + ); + assert.ok(result.ok && result.resource.kind === "interface", JSON.stringify(result.diagnostics)); + const iface = result.resource.revision; + const pkg = compileCapabilityResourceSource( + 'import interface DeviceController;package Driver id "driver" revision "driver@1" {query Enabled id "enabled" root workspace DeviceController document "enabled.graphql" operation "Enabled" {max rows 100;}}', + { source, environment: { interfaces: new Map([["DeviceController", iface]]), interfaceClosure: [iface] } }, + ); + assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics)); + const declaration = pkg.resource.revision.queries![0]!; + const metadata = JSON.stringify({ + schemaVersion: 1, + controllers: [ + { + id: "driver", + registry: "enabled", + subject: "device-controller@1", + snapshot: "device:snapshot:call", + remote: "device:apply:call", + input: "value", + }, + ], + }); + const check = async (document: string) => + parseControllerCatalog(metadata, [iface], [await compileQuery(declaration, [iface], async () => document)]); + assert.equal( + (await check("query Enabled {root {objects(all:64,where:{enabled:{eq:true}}) {entries {node {_qx {ref}}}}}}")) + .length, + 1, + ); + for (const document of [ + "query Enabled {root {objects(first:64) {entries {node {_qx {ref}}}}}}", + "query Enabled {root {objects(all:65) {entries {node {_qx {ref}}}}}}", + "query Enabled {root {devices:objects(all:64) {entries {node {_qx {ref}}}}}}", + "query Enabled {root {objects(all:64) {entries {node {enabled}}}}}", + "query Enabled($enabled:Boolean!) {root {objects(all:64,where:{enabled:{eq:$enabled}}) {entries {node {_qx {ref}}}}}}", + ]) + await assert.rejects(check(document), /registry|Controller/); + assert.throws(() => parseControllerCatalog(metadata, [iface], []), /registry/); +}); diff --git a/test/emit-controllers.ts b/test/emit-controllers.ts new file mode 100644 index 0000000..4f79820 --- /dev/null +++ b/test/emit-controllers.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { readFile, mkdir, writeFile } from "node:fs/promises"; +import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/index.js"; +import { compileQuery } from "../src/query/compile.js"; +import { parseControllerCatalog } from "../src/capability-model/controllers.js"; +import { + compileExecutionPlan, + generateInterfaceClientBindings, + generatePackageQueryClientBindings, +} from "../src/execution-world/index.js"; +const read = (file: string) => readFile("test/fixtures/controller-device/" + file, "utf8"); +const source = { repository: "https://example.test/device.git", commit: "a".repeat(40) }; +const device = compileCapabilityResourceSource(await read("Device.qx"), { source }); +assert.ok(device.ok && device.resource.kind === "interface", JSON.stringify(device.diagnostics)); +const control = compileCapabilityResourceSource(await read("DeviceController.qx"), { source }); +assert.ok(control.ok && control.resource.kind === "interface", JSON.stringify(control.diagnostics)); +const iface = device.resource.revision, + controllerInterface = control.resource.revision, + environment = { + interfaces: new Map([ + ["Device", iface], + ["DeviceController", controllerInterface], + ]), + interfaceClosure: [iface, controllerInterface], + }; +const driver = compileCapabilityResourceSource(await read("Driver.qx"), { source, environment }); +assert.ok(driver.ok && driver.resource.kind === "package", JSON.stringify(driver.diagnostics)); +const pkg = driver.resource.revision; +pkg.checkedQueries = await Promise.all( + pkg.queries!.map((query) => compileQuery(query, [iface, controllerInterface], read)), +); +pkg.controllers = parseControllerCatalog( + JSON.stringify({ + schemaVersion: 1, + controllers: [ + { + id: "device-driver", + registry: "enabled", + subject: "device-controller@1", + snapshot: "device:snapshot:call", + remote: "device:apply:call", + input: "value", + }, + ], + }), + [iface, controllerInterface], + pkg.checkedQueries, +); +for (const variant of ["controller", "controller-next"]) { + const extra = variant.endsWith("next") ? 'atom Dashboard id "dashboard";' : ""; + const result = compileCapabilitySource((await read("workspace.qx")).replace(/}\s*$/, extra + "\n}"), "workspace.qx", { + ...environment, + packages: new Map([["Driver", pkg]]), + }); + assert.ok(result.ok, JSON.stringify(result.diagnostics)); + const plan = compileExecutionPlan(result.workspace), + root = "dist/managed/" + variant; + await mkdir(root, { recursive: true }); + await writeFile(root + "/client.ts", generateInterfaceClientBindings({ Device: iface })); + await writeFile(root + "/queries.ts", generatePackageQueryClientBindings(pkg)); + for (const [file, value] of Object.entries({ + "candidate.json": result.workspace, + "execution-plan.json": plan, + "entry.json": null, + "source.json": { kind: "workspace", ...source }, + })) + await writeFile(root + "/" + file, JSON.stringify(value)); +} diff --git a/test/emit-managed.ts b/test/emit-managed.ts index 7861b67..f627c77 100644 --- a/test/emit-managed.ts +++ b/test/emit-managed.ts @@ -169,3 +169,5 @@ package Owner id "package:owner" revision "package:owner@1" { })) await writeFile(`${root}/${file}`, JSON.stringify(value)); } + +await import("./emit-controllers.js"); diff --git a/test/fixtures/controller-device/Device.qx b/test/fixtures/controller-device/Device.qx new file mode 100644 index 0000000..2bd1c77 --- /dev/null +++ b/test/fixtures/controller-device/Device.qx @@ -0,0 +1,11 @@ +external atom Display id "device:display"; +type Request=record {url:string;text:string;version:int32;}; +interface Device id "device" revision "device@1" { + static operation create id "device:create" : string -> atom-ref {call id "device:create:call" execution transaction targets [browser,server];} + queryable value enabled id "device:enabled" : bool {get id "device:enabled:get" execution snapshot targets [browser,server];set id "device:enabled:set" execution transaction targets [browser,server];} + queryable value requested id "device:requested" : Request {get id "device:requested:get" execution snapshot targets [browser,server];} + queryable value observed id "device:observed" : optional {get id "device:observed:get" execution snapshot targets [browser,server];} + operation requestText id "device:request" : string -> unit {call id "device:request:call" execution transaction targets [browser,server];} + operation recordObservation id "device:record" : Request -> unit {call id "device:record:call" execution transaction targets [server];} + operation remove id "device:remove" : unit -> unit {call id "device:remove:call" execution transaction targets [server];} +} diff --git a/test/fixtures/controller-device/DeviceController.qx b/test/fixtures/controller-device/DeviceController.qx new file mode 100644 index 0000000..8ee3cb5 --- /dev/null +++ b/test/fixtures/controller-device/DeviceController.qx @@ -0,0 +1,6 @@ +type Request=record {url:string;text:string;version:int32;}; +interface DeviceController id "device-controller" revision "device-controller@1" { + queryable portable value enabled id "controller:enabled" : bool {get id "controller:enabled:get" execution snapshot targets [server];} + operation snapshot id "device:snapshot" : unit -> optional {call id "device:snapshot:call" execution snapshot targets [server];} + operation apply id "device:apply" : optional -> unit {call id "device:apply:call" execution remote;} +} diff --git a/test/fixtures/controller-device/Driver.qx b/test/fixtures/controller-device/Driver.qx new file mode 100644 index 0000000..575c284 --- /dev/null +++ b/test/fixtures/controller-device/Driver.qx @@ -0,0 +1,15 @@ +import interface Device; +import interface DeviceController; +external atom Display id "device:display"; +type Request=record {url:string;text:string;version:int32;}; +package Driver id "driver" revision "driver@1" { + function create id "driver:create" : string -> atom-ref execution transaction targets [browser,server] requires {creation device id "device" : Display input record {enabled:bool;requested:Request;observed:optional;};}; + operation request id "driver:request" : string -> unit mode call execution transaction targets [browser,server] receiver any requires {state requested id "requested" : Request [read,write];}; + operation snapshot id "driver:snapshot" : unit -> optional mode call execution snapshot targets [server] receiver any requires {interface data id "data" : Device using [enabled.get,requested.get,observed.get];}; + operation recordObservation id "driver:record" : Request -> unit mode call execution transaction targets [server] receiver any requires {state requested id "requested" : Request [read];state observed id "observed" : optional [write];}; + operation apply id "driver:apply" : optional -> unit mode call execution remote receiver any requires {interface device id "device" : Device using [recordObservation.call];}; + operation enabled id "driver:enabled" : unit -> bool mode call execution snapshot targets [server] receiver any requires {interface data id "data" : Device using [enabled.get];}; + query Enabled id "enabled" root workspace DeviceController document "enabled.graphql" operation "Enabled" {max rows 64;} + query Inputs id "inputs" root Device document "inputs.graphql" operation "Inputs" {replica;} + query View id "view" root Device document "inputs.graphql" operation "Inputs" {local from Inputs;watch;} +} diff --git a/test/fixtures/controller-device/enabled.graphql b/test/fixtures/controller-device/enabled.graphql new file mode 100644 index 0000000..64b56c9 --- /dev/null +++ b/test/fixtures/controller-device/enabled.graphql @@ -0,0 +1 @@ +query Enabled {root {objects(all:64, where:{enabled:{eq:true}}) {entries {node {_qx {ref}}}}}} diff --git a/test/fixtures/controller-device/inputs.graphql b/test/fixtures/controller-device/inputs.graphql new file mode 100644 index 0000000..f330a2b --- /dev/null +++ b/test/fixtures/controller-device/inputs.graphql @@ -0,0 +1 @@ +query Inputs {root {enabled requested {url text version} observed {url text version}}} diff --git a/test/fixtures/controller-device/workspace.qx b/test/fixtures/controller-device/workspace.qx new file mode 100644 index 0000000..595a63f --- /dev/null +++ b/test/fixtures/controller-device/workspace.qx @@ -0,0 +1,25 @@ +workspace Controllers id "workspace:controllers" revision "workspace:controllers@1" commit "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { + import interface Device; + import interface DeviceController; + import package Driver; + atom Display id "device:display"; + conform Display as Device id "display:device" { + private state Enabled id "display:enabled" on Display : bool policy register; + private state Requested id "display:requested" on Display : record {url:string;text:string;version:int32;} policy register; + private state Observed id "display:observed" on Display : optional policy register; + initialize {enabled to state Enabled;requested to state Requested;observed to state Observed;}; + bind create.call to package Driver.create with {device to creation Display with {Device from input;};}; + bind enabled.get to state Enabled.read; + bind enabled.set to state Enabled.write; + bind requested.get to state Requested.read; + bind observed.get to state Observed.read; + bind requestText.call to package Driver.request with {requested to state Requested;}; + bind recordObservation.call to package Driver.recordObservation with {requested to state Requested;observed to state Observed;}; + bind remove.call to lifecycle.delete; + } + conform Display as DeviceController id "display:controller" { + bind enabled.get to package Driver.enabled with {data to interface Device;}; + bind snapshot.call to package Driver.snapshot with {data to interface Device;}; + bind apply.call to package Driver.apply with {device to interface Device;}; + } +}