Add composable interface operation forwarding

Support bind member.operation to interface Target<Args>.member.operation.
Check exact signatures and scopes, same-receiver conformances, cycles and
watch pairing. Resolve chains for native queries, editing and dispatch while
retaining the terminal conformance owner and injected package dependencies.
Track forwarded providers in dependency and semantic-major review machinery.

Cover native get/set/watch chains, generic targets, invalid bindings, package
forwarding, runtime owner authority and watch cancellation. Document the
language feature and keep Studio search adoption explicitly pending.
No push or deployment.
This commit is contained in:
Timothy J. Aveni
2026-09-19 10:18:47 -07:00
parent 2594403503
commit 1aa55f7a35
10 changed files with 821 additions and 467 deletions
+1
View File
@@ -301,6 +301,7 @@ operationName
operationProvider operationProvider
: STATE identifier DOT statePrimitive : STATE identifier DOT statePrimitive
| EDGE identifier DOT identifier DOT edgePrimitive | EDGE identifier DOT identifier DOT edgePrimitive
| INTERFACE identifier typeArguments? DOT memberOperationRef
| PACKAGE identifier DOT identifier typeArguments? dependencyBindingBlock? | PACKAGE identifier DOT identifier typeArguments? dependencyBindingBlock?
; ;
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+30
View File
@@ -1787,6 +1787,36 @@ const lowerConformance = (
return []; return [];
} }
const provider = bindingContext.operationProvider(); const provider = bindingContext.operationProvider();
if (provider.INTERFACE()) {
const targetName = identifier(provider.identifier(0));
let target = requireSymbol(state, state.interfaces, targetName, provider, "interface");
if (!target) return [];
if (provider.typeArguments() || target.definition?.template) {
const id = new TypeSubstitution({ ...state.types.environment(), self: atomId }).application(
state.types.interfaceByName(targetName, provider.typeArguments()),
);
target = interfaceSymbolFor(state.types.definitions.get(id)!);
}
const ref = provider.memberOperationRef()!;
const member = target.members.get(identifier(ref.identifier()));
const targetOperation = member?.operations.get(text(ref.operationName()));
if (!targetOperation) {
loweringIssue(
state,
provider,
"unknown-operation",
"Forwarding target must name an interface member operation",
);
return [];
}
return [
{
operationId,
binding: { kind: "interface", interfaceRevisionId: target.revisionId, operationId: targetOperation },
...(bindingContext.stringLiteral() ? { queryReason: stringValue(bindingContext.stringLiteral()!) } : {}),
},
];
}
if (provider.STATE()) { if (provider.STATE()) {
const attachmentName = identifier(provider.identifier(0)); const attachmentName = identifier(provider.identifier(0));
const attachment = requireSymbol(state, state.attachments, attachmentName, provider, "attachment"); const attachment = requireSymbol(state, state.attachments, attachmentName, provider, "attachment");
+16 -2
View File
@@ -167,8 +167,22 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId)); parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
reviews?.add(conformanceIdentity(conformance)); reviews?.add(conformanceIdentity(conformance));
for (const operation of conformance.operationBindings) { for (const operation of conformance.operationBindings) {
if (operation.binding.kind !== "package") continue; let provider: Binding | undefined = operation.binding;
const revisionId = operation.binding.packageRevisionId; const visited = new Set<string>();
while (provider?.kind === "interface") {
const forward: Extract<Binding, { kind: "interface" }> = provider;
const key = `${provider.interfaceRevisionId}\0${provider.operationId}`;
if (visited.has(key)) throw new Error("Forwarding cycle in evolution contract");
visited.add(key);
const target = workspace.conformances.find(
(c) => c.atomId === conformance.atomId && c.interfaceRevisionId === forward.interfaceRevisionId,
);
if (!target) throw new Error("Missing forwarding conformance in evolution contract");
reviews?.add(conformanceIdentity(target));
provider = target.operationBindings.find((entry) => entry.operationId === forward.operationId)?.binding;
}
if (provider?.kind !== "package") continue;
const revisionId = provider.packageRevisionId;
const pkg = workspace.packageImports.find((entry) => entry.revisionId === revisionId); const pkg = workspace.packageImports.find((entry) => entry.revisionId === revisionId);
if (pkg) reviews?.add(pkg.packageId); if (pkg) reviews?.add(pkg.packageId);
} }
+36
View File
@@ -0,0 +1,36 @@
import type {
AtomId,
Binding,
Conformance,
InterfaceRevisionId,
OperationBinding,
OperationId,
WorkspaceRevision,
} from "./types.js";
export type ConcreteBinding = Exclude<Binding, { kind: "interface" }>;
export type ResolvedBinding = { conformance: Conformance; entry: OperationBinding; binding: ConcreteBinding };
/** Follow public operations on the same receiver, never private attachment names. */
export function resolveForwardedBinding(
workspace: Pick<WorkspaceRevision, "conformances">,
atomId: AtomId,
interfaceRevisionId: InterfaceRevisionId,
operationId: OperationId,
): ResolvedBinding {
const visited = new Set<string>();
for (;;) {
const key = `${interfaceRevisionId}\0${operationId}`;
if (visited.has(key)) throw new Error(`Forwarding cycle at ${interfaceRevisionId}/${operationId}`);
visited.add(key);
const conformance = workspace.conformances.find(
(c) => c.atomId === atomId && c.interfaceRevisionId === interfaceRevisionId,
);
const entry = conformance?.operationBindings.find((b) => b.operationId === operationId);
if (!conformance || !entry)
throw new Error(`Missing forwarding target ${atomId} as ${interfaceRevisionId}/${operationId}`);
if (entry.binding.kind !== "interface") return { conformance, entry, binding: entry.binding };
interfaceRevisionId = entry.binding.interfaceRevisionId;
operationId = entry.binding.operationId;
}
}
+5
View File
@@ -337,6 +337,11 @@ export interface RelationshipMaterialization {
} }
export type Binding = export type Binding =
| {
kind: "interface";
interfaceRevisionId: InterfaceRevisionId;
operationId: OperationId;
}
| { | {
kind: "state"; kind: "state";
slotId: SlotId; slotId: SlotId;
+80 -2
View File
@@ -1,3 +1,4 @@
import { resolveForwardedBinding, type ConcreteBinding } from "./forwarding.js";
import type { import type {
AtomConstructorBinding, AtomConstructorBinding,
AtomId, AtomId,
@@ -66,6 +67,7 @@ export type CapabilityValidationIssueCode =
| "invalid-state-binding" | "invalid-state-binding"
| "invalid-edge-binding" | "invalid-edge-binding"
| "invalid-package-binding" | "invalid-package-binding"
| "invalid-forwarding-binding"
| "invalid-dependency-binding" | "invalid-dependency-binding"
| "private-attachment-access" | "private-attachment-access"
| "incompatible-package-receiver" | "incompatible-package-receiver"
@@ -1521,7 +1523,32 @@ const validateConformances = (
continue; continue;
} }
const operation = operationEntry.operation; const operation = operationEntry.operation;
const binding = entry.binding; let binding: ConcreteBinding;
if (entry.binding.kind === "interface") {
const target = indexes.interfaces
.get(entry.binding.interfaceRevisionId)
?.operations.get(entry.binding.operationId)?.operation;
if (!target || !signaturesMatch(operation, target) || operation.scope !== target.scope) {
issue(
issues,
"invalid-forwarding-binding",
bindingPath,
"Forwarding requires matching signatures, modes and receiver scopes",
);
continue;
}
try {
binding = resolveForwardedBinding(
workspace,
conformance.atomId,
conformance.interfaceRevisionId,
entry.operationId,
).binding;
} catch (error) {
issue(issues, "invalid-forwarding-binding", bindingPath, String(error));
continue;
}
} else binding = entry.binding;
const member = operationEntry.member; const member = operationEntry.member;
if ( if (
member.kind !== "operation" && member.kind !== "operation" &&
@@ -1556,6 +1583,9 @@ const validateConformances = (
); );
continue; continue;
} }
// The target conformance validates the concrete provider and its private
// authority. Forwarding exposes that operation, not its attachment ports.
if (entry.binding.kind === "interface") continue;
if (operation.scope === "class" && binding.kind !== "package") { if (operation.scope === "class" && binding.kind !== "package") {
issue( issue(
issues, issues,
@@ -1760,6 +1790,49 @@ const validateConformances = (
}); });
} }
for (const member of interfaceEntry.revision.members) {
const start = member.operations.find((op) => op.mode === "watch-start");
const stop = member.operations.find((op) => op.mode === "watch-stop");
if (!start || !stop || ![start, stop].some((op) => bindings.get(op.id)?.kind === "interface")) continue;
try {
const a = resolveForwardedBinding(workspace, conformance.atomId, conformance.interfaceRevisionId, start.id);
const b = resolveForwardedBinding(workspace, conformance.atomId, conformance.interfaceRevisionId, stop.id);
const target = indexes.interfaces
.get(a.conformance.interfaceRevisionId)
?.revision.members.find((m) => m.operations.some((op) => op.id === a.entry.operationId));
if (a.conformance !== b.conformance || !target?.operations.some((op) => op.id === b.entry.operationId))
issue(
issues,
"invalid-forwarding-binding",
path,
"Forwarded watch start/stop must resolve to the same target member",
);
const get = member.operations.find((op) => op.displayName === (member.kind === "value" ? "get" : "resolve"));
if (get && (a.binding.kind === "state" || a.binding.kind === "edge")) {
const read = resolveForwardedBinding(
workspace,
conformance.atomId,
conformance.interfaceRevisionId,
get.id,
).binding;
const matches =
a.binding.kind === "state"
? read.kind === "state" && read.slotId === a.binding.slotId
: read.kind === "edge" &&
read.edgeTypeId === a.binding.edgeTypeId &&
read.projectionId === a.binding.projectionId;
if (!matches)
issue(
issues,
"invalid-forwarding-binding",
path,
"Native forwarded watch must observe its getter's storage",
);
}
} catch (error) {
issue(issues, "invalid-forwarding-binding", path, String(error));
}
}
const materializedMembers = new Set<string>(); const materializedMembers = new Set<string>();
for (const [materializationIndex, materialization] of (conformance.relationshipMaterializations ?? []).entries()) { for (const [materializationIndex, materialization] of (conformance.relationshipMaterializations ?? []).entries()) {
const materializationPath = `${path}.relationshipMaterializations[${materializationIndex}]`; const materializationPath = `${path}.relationshipMaterializations[${materializationIndex}]`;
@@ -2063,7 +2136,10 @@ export const resolveOperationBinding = (
atomId: AtomId, atomId: AtomId,
interfaceRevisionId: InterfaceRevisionId, interfaceRevisionId: InterfaceRevisionId,
operationId: OperationId, operationId: OperationId,
): Binding | undefined => resolveConformance(plan, atomId, interfaceRevisionId)?.operationBindings.get(operationId); ): ConcreteBinding | undefined => {
if (!resolveConformance(plan, atomId, interfaceRevisionId)?.operationBindings.has(operationId)) return undefined;
return resolveForwardedBinding(plan.source, atomId, interfaceRevisionId, operationId).binding;
};
export type ResolvedOperationPlan = export type ResolvedOperationPlan =
| { | {
@@ -2259,6 +2335,8 @@ export const computeCapabilityClosure = (
attachments.add(binding.slotId); attachments.add(binding.slotId);
} else if (binding.kind === "edge") { } else if (binding.kind === "edge") {
attachments.add(binding.edgeTypeId); attachments.add(binding.edgeTypeId);
} else if (binding.kind === "interface") {
queued.push({ atomId: root.atomId, interfaceRevisionId: binding.interfaceRevisionId });
} else { } else {
packages.add(binding.packageRevisionId); packages.add(binding.packageRevisionId);
includeDependencies(root.atomId, binding.dependencies); includeDependencies(root.atomId, binding.dependencies);
+11 -2
View File
@@ -10,6 +10,7 @@ import type {
import { QueryCompileError, type CheckedQuery } from "./types.js"; import { QueryCompileError, type CheckedQuery } from "./types.js";
import { queryRuntimePlan } from "./proto.js"; import { queryRuntimePlan } from "./proto.js";
import { canonicalJson } from "../capability-model/evolution.js"; import { canonicalJson } from "../capability-model/evolution.js";
import { resolveForwardedBinding } from "../capability-model/forwarding.js";
export interface LinkedQueryField { export interface LinkedQueryField {
atomId: AtomId; atomId: AtomId;
@@ -74,7 +75,12 @@ export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] {
const provider = conformance.operationBindings.find((entry) => entry.operationId === getter.id); const provider = conformance.operationBindings.find((entry) => entry.operationId === getter.id);
if (!provider) if (!provider)
throw new QueryCompileError("QUERY_CONTRACT", `Missing binding ${conformance.atomId}.${getter.id}`); throw new QueryCompileError("QUERY_CONTRACT", `Missing binding ${conformance.atomId}.${getter.id}`);
const binding = provider.binding; const binding = resolveForwardedBinding(
workspace,
conformance.atomId,
conformance.interfaceRevisionId,
getter.id,
).binding;
if (binding.kind === "package") { if (binding.kind === "package") {
if (effect.execution !== "rpc-permitted") if (effect.execution !== "rpc-permitted")
throw new QueryCompileError( throw new QueryCompileError(
@@ -95,7 +101,10 @@ export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] {
); );
const start = member.operations.find((op) => op.displayName === "watch-start"); const start = member.operations.find((op) => op.displayName === "watch-start");
const stop = member.operations.find((op) => op.displayName === "watch-stop"); const stop = member.operations.find((op) => op.displayName === "watch-stop");
const watch = start && stop && conformance.operationBindings.find((entry) => entry.operationId === start.id); const watch =
start && stop && conformance.operationBindings.some((entry) => entry.operationId === start.id)
? resolveForwardedBinding(workspace, conformance.atomId, conformance.interfaceRevisionId, start.id)
: undefined;
if ( if (
binding.kind === "state" && binding.kind === "state" &&
watch && watch &&
+149
View File
@@ -0,0 +1,149 @@
import assert from "node:assert/strict";
import test from "node:test";
import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/index.js";
import { compileWorkspaceRevision, resolveOperationPlan } from "../src/capability-model/validation.js";
import { capabilityId, type InterfaceRevision } from "../src/capability-model/types.js";
import { compileQuery } from "../src/query/compile.js";
import { linkQueries } from "../src/query/link.js";
import { compileCapabilityFixture, fixtureId } from "./fixtures/capability-model.js";
const source = { repository: "https://example.test/forwarding.git", commit: "a".repeat(40) };
function fixture(transform = (s: string) => s, generic = false) {
const interfaces = new Map<string, InterfaceRevision>();
for (const name of ["Owner", "Alias", "Relay"]) {
const result = compileCapabilityResourceSource(
`interface ${name}${generic && name === "Owner" ? "<value T>" : ""} id "${name}" revision "${name}@1" {
queryable value title id "${name}:title" : ${generic && name === "Owner" ? "T" : "string"} {
get id "${name}:get"; set id "${name}:set";
watch start id "${name}:start" stop id "${name}:stop";
}
}`,
{ source },
);
assert.ok(result.ok, JSON.stringify(result.diagnostics));
if (result.resource.kind !== "interface") throw new Error("interface expected");
interfaces.set(name, result.resource.revision);
}
const forward = (name: string, target: string) => `conform Item as ${name} id "${name}-conformance" {
bind title.get to interface ${target}.title.get;
bind title.set to interface ${target}.title.set;
bind title.watch-start to interface ${target}.title.watch-start;
bind title.watch-stop to interface ${target}.title.watch-stop;
}`;
const text = `workspace Test id "ws" revision "ws@1" commit "${source.commit}" {
import interface Owner; import interface Alias; import interface Relay;
atom Item id "item";
${forward("Relay", "Alias")}
${forward("Alias", generic ? "Owner<string>" : "Owner")}
conform Item as Owner${generic ? "<string>" : ""} id "owner-conformance" {
private state Title id "title" on Item : string policy optimistic-register default "";
bind title to state Title;
}
}`;
return { result: compileCapabilitySource(transform(text), "workspace.qx", { interfaces }), interfaces };
}
test("bind chains forward native reads, writes and watches without changing storage ownership", async () => {
const { result, interfaces } = fixture();
assert.ok(result.ok, JSON.stringify(result.diagnostics));
const compiled = compileWorkspaceRevision(result.workspace);
assert.ok(compiled.ok);
for (const [operation, primitive] of [
["get", "read"],
["set", "write"],
["start", "watch-start"],
["stop", "watch-stop"],
]) {
const resolved = resolveOperationPlan(
compiled.plan,
capabilityId.atom("item"),
capabilityId.interfaceRevision("Relay@1"),
capabilityId.operation(`Relay:${operation}`),
);
assert.equal(resolved?.binding.kind, "state");
if (resolved?.kind !== "state") throw new Error("state expected");
assert.equal(resolved.binding.primitive, primitive);
assert.deepEqual(resolved.attachment.owner, {
kind: "conformance",
atomId: "item",
interfaceRevisionId: "Owner@1",
});
}
const pkg = compileCapabilityResourceSource(
`import interface Relay;
package Views id "views" revision "views@1" { query Rows id "rows" root workspace Relay document "rows.graphql" operation "Rows" {max rows 10; watch;} }`,
{ source, environment: { interfaces } },
);
assert.ok(pkg.ok, JSON.stringify(pkg.diagnostics));
if (pkg.resource.kind !== "package") throw new Error("package expected");
const revision = pkg.resource.revision;
revision.checkedQueries = [
await compileQuery(
revision.queries![0]!,
[...interfaces.values()],
async () => `query Rows {root {objects(first: 10) {entries {node {title @live}}}}}`,
),
];
result.workspace.packageImports.push(revision);
const linked = linkQueries(result.workspace)[0]!;
assert.equal(linked.fields[0]!.binding.kind, "state");
assert.equal(linked.attachments[0]!.id, "title");
});
test("forwarding rejects cycles, wrong signatures, missing targets, and direct private access", () => {
for (const transform of [
(s: string) => s.replaceAll("interface Owner.title", "interface Relay.title"),
(s: string) => s.replace("interface Owner.title.get", "interface Owner.title.set"),
(s: string) =>
s
.replace("conform Item as Owner", "conform Other as Owner")
.replace('atom Item id "item";', 'atom Item id "item"; atom Other id "other";')
.replace("on Item :", "on Other :"),
(s: string) => s.replace("interface Owner.title.get", "state Title.read"),
(s: string) => s.replace("interface Owner.title.watch-stop", "interface Relay.title.watch-stop"),
])
assert.equal(fixture(transform).result.ok, false);
});
test("forwarding closes generic targets and rejects incompatible applications", () => {
const good = fixture(undefined, true).result;
assert.ok(good.ok, JSON.stringify(good.diagnostics));
const bad = fixture(
(s) => s.replace("interface Owner<string>.title.get", "interface Owner<int32>.title.get"),
true,
).result;
assert.equal(bad.ok, false);
});
test("forwarded package actions retain the provider and its dependency ports", () => {
const fixture = compileCapabilityFixture();
assert.ok(fixture.ok, JSON.stringify(fixture.diagnostics));
const ws = fixture.workspace;
const target = ws.interfaceImports.find((i) => i.revisionId === fixtureId.summaryV1)!;
const alias = {
...structuredClone(target),
interfaceId: capabilityId.interface("alias"),
revisionId: capabilityId.interfaceRevision("alias@1"),
};
ws.interfaceImports.push(alias);
ws.conformances.push({
atomId: fixtureId.project,
interfaceRevisionId: alias.revisionId,
id: capabilityId.conformance("alias-owner"),
privateAttachments: [],
relationshipMaterializations: [],
operationBindings: [
{
operationId: fixtureId.summaryGet,
binding: { kind: "interface", interfaceRevisionId: fixtureId.summaryV1, operationId: fixtureId.summaryGet },
},
],
});
const result = compileWorkspaceRevision(ws);
assert.ok(result.ok, JSON.stringify(result));
const plan = resolveOperationPlan(result.plan, fixtureId.project, alias.revisionId, fixtureId.summaryGet);
assert.equal(plan?.kind, "package");
if (plan?.kind !== "package") throw new Error("package expected");
assert.equal(plan.binding.exportId, fixtureId.summaryGetExport);
assert.ok(plan.dependencies.length > 0);
});