Add explicit CRDT edit contracts and structured query values

This commit is contained in:
Timothy J. Aveni
2026-09-19 22:33:20 -07:00
parent 0555c929b1
commit 4c0c404a39
27 changed files with 3272 additions and 2745 deletions
+6 -4
View File
@@ -16,7 +16,7 @@ export type ValueTypeExpression =
| Extract<ValueType, { kind: "builtin" | "scalar" | "message" }>
| { kind: "parameter"; parameterId: string }
| { kind: "record"; fields: Record<string, ValueTypeExpression> }
| { kind: "list" | "optional"; value: ValueTypeExpression }
| { kind: "list" | "optional" | "change"; value: ValueTypeExpression }
| { kind: "object-ref"; expectation: ObjectTypeExpression }
| { kind: "alias"; definitionId: string; arguments: TypeArgumentExpression[] };
@@ -184,10 +184,10 @@ export const isStorableType = (
case "list":
case "optional":
return visit(value.value, depth + 1);
// Records currently have RPC codecs, not ordinary-state persistence codecs.
// A generic bound must not promise storage that the installed model rejects.
case "record":
case "change":
return false;
case "record":
return Object.values(value.fields).every((field) => visit(field, depth + 1));
}
};
return visit(type, 0);
@@ -216,6 +216,7 @@ export const canonicalTypeArgument = (argument: ClosedTypeArgument): string => {
return ["message", value.descriptorId];
case "object-ref":
return ["object-ref", target(value.expectation)];
case "change":
case "list":
case "optional":
return [value.kind, type(value.value, depth + 1)];
@@ -323,6 +324,7 @@ export class TypeSubstitution {
return { kind: "scalar", name: expression.name };
case "message":
return { kind: "message", descriptorId: expression.descriptorId };
case "change":
case "list":
case "optional":
return { kind: expression.kind, value: this.value(expression.value, `${path}.${expression.kind}`, depth + 1) };
+3 -2
View File
@@ -67,7 +67,8 @@ export type ValueType =
| { kind: "record"; fields: Record<string, ValueType> }
| { kind: "object-ref"; expectation: ObjectExpectation }
| { kind: "optional"; value: ValueType }
| { kind: "list"; value: ValueType };
| { kind: "list"; value: ValueType }
| { kind: "change"; value: ValueType };
export const valueType = {
unit: { kind: "builtin", name: "unit" } as const,
@@ -214,7 +215,7 @@ export interface EdgeDefinition {
export type PersistentAttachment = StateSlotDefinition | EdgeDefinition;
export type StatePrimitive = "read" | "write" | "watch-start" | "watch-stop";
export type StatePrimitive = "read" | "write" | "edit" | "watch-start" | "watch-stop";
export type EdgePrimitive = "resolve" | "connect" | "disconnect" | "watch-start" | "watch-stop";
+50 -5
View File
@@ -154,6 +154,8 @@ const typeLabel = (type: ValueType): string => {
return type.expectation.kind === "atom"
? `object:atom:${type.expectation.atomId}`
: `object:interface:${type.expectation.interfaceRevisionId}`;
case "change":
return `change<${typeLabel(type.value)}>`;
case "optional":
return `optional<${typeLabel(type.value)}>`;
case "list":
@@ -189,6 +191,7 @@ export const valueTypesEqual = (left: ValueType, right: ValueType): boolean => {
? left.expectation.atomId === (other as typeof left.expectation).atomId
: left.expectation.interfaceRevisionId === (other as typeof left.expectation).interfaceRevisionId;
}
case "change":
case "optional":
case "list":
return valueTypesEqual(left.value, (right as typeof left).value);
@@ -239,6 +242,7 @@ const validateValueType = (
case "message":
requireText(issues, type.descriptorId, `${path}.descriptorId`, "Message descriptor identity");
return;
case "change":
case "optional":
case "list":
validateValueType(issues, type.value, `${path}.value`, indexes);
@@ -341,6 +345,8 @@ export const statePrimitiveSignature = (slot: StateSlotDefinition, primitive: St
inputType: valueType.unit,
outputType: slot.valueType,
};
case "edit":
return { mode: "call", inputType: { kind: "change", value: slot.valueType }, outputType: valueType.unit };
case "write":
return {
mode: "call",
@@ -532,9 +538,19 @@ const collectIdentityIndexes = (
if (member.kind === "relationship" && contract === "rpc-permitted")
issue(issues, "invalid-query-contract", memberPath, "RPC query relationships are not supported");
if (member.kind === "value") {
const type = member.valueType.kind === "optional" ? member.valueType.value : member.valueType;
if (type.kind !== "scalar")
issue(issues, "invalid-query-contract", memberPath, "Queryable values must be scalar or optional scalar");
const queryValue = (type: ValueType): boolean =>
type.kind === "scalar" ||
((type.kind === "optional" || type.kind === "list") && queryValue(type.value)) ||
(type.kind === "record" &&
Object.keys(type.fields).length > 0 &&
Object.values(type.fields).every(queryValue));
if (!queryValue(member.valueType))
issue(
issues,
"invalid-query-contract",
memberPath,
"Queryable values require scalar leaves in optional, list, or nonempty record shapes",
);
}
}
requireText(issues, member.id, `${memberPath}.id`, "Member ID");
@@ -837,7 +853,8 @@ const validateAttachments = (issues: CapabilityValidationIssue[], indexes: Valid
validateValueType(issues, attachment.valueType, `${path}.valueType`, indexes);
const containsRpcType = (type: ValueType): boolean =>
type.kind === "object-ref" ||
type.kind === "record" ||
type.kind === "change" ||
(type.kind === "record" && Object.values(type.fields).some(containsRpcType)) ||
((type.kind === "optional" || type.kind === "list") && containsRpcType(type.value));
if (
containsRpcType(attachment.valueType) ||
@@ -847,7 +864,7 @@ const validateAttachments = (issues: CapabilityValidationIssue[], indexes: Valid
issues,
"invalid-attachment",
`${path}.valueType`,
"Managed object references belong in graph relationships, not ordinary state; record types are RPC-only",
"Managed object references belong in graph relationships; captured changes are transport-only",
);
}
if (attachment.storagePolicy.kind === "crdt-document") {
@@ -1287,6 +1304,12 @@ const validateBoundDependencies = (
: `State ${stateBinding.slotId} is not attached to receiver atom ${params.atomId}`,
);
}
if (
requirement.primitives.includes("edit") &&
(attachment.attachment.storagePolicy.kind !== "crdt-document" ||
!valueTypesEqual(attachment.attachment.valueType, attachment.attachment.storagePolicy.updateType))
)
issue(issues, "invalid-dependency-binding", path, "Edit ports require a matching CRDT slot");
if (!valueTypesEqual(requirement.valueType, attachment.attachment.valueType)) {
issue(
issues,
@@ -1579,6 +1602,17 @@ const validateConformances = (
}
} else binding = entry.binding;
const member = operationEntry.member;
if (
member.kind === "value" &&
operation.displayName === "edit" &&
(binding.kind !== "state" || binding.primitive !== "edit")
)
issue(
issues,
"invalid-state-binding",
bindingPath,
"CRDT edit operations must resolve to a native state edit capability",
);
if (
member.kind !== "operation" &&
member.queryRead &&
@@ -1652,6 +1686,17 @@ const validateConformances = (
"State primitives may only implement value members",
);
}
if (
binding.primitive === "edit" &&
(attachment.attachment.storagePolicy.kind !== "crdt-document" ||
!valueTypesEqual(attachment.attachment.valueType, attachment.attachment.storagePolicy.updateType))
)
issue(
issues,
"invalid-state-binding",
bindingPath,
"Edit requires a CRDT slot with a matching materialized profile type",
);
const expected = statePrimitiveSignature(attachment.attachment, binding.primitive);
if (!signaturesMatch(operation, expected)) {
issue(