Files
quixos-protocol/src/capability-model/evolution.ts
T
Timothy J. Aveni 52803dda05 Implement capability generics, checked package specializations and CRUD scaffolding
Add kinded parameters, capability bounds, Self, aliases and closed application identities. Check generic implementations universally and build candidate-specific codecs and descriptors from immutable schemas. Preserve lexical aliases and exact dispatch identities in package and host bindings.

Add an imperative CRUD+index domain scaffold with explicit soft-deletion semantics, source/codegen regression coverage, installed CLI tests and an authoring guide. Existing Web Studio opaque props and class-level create-menu migration are separate from the implemented language core.
2026-09-16 10:42:12 -07:00

400 lines
18 KiB
TypeScript

import { createHash } from "node:crypto";
import type { Binding, Conformance, DependencyBinding, PersistentAttachment, WorkspaceRevision } from "./types.js";
import { validateWorkspaceRevision } from "./validation.js";
/** Content hashing is independent of JSON object insertion order, not array order. */
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
export const canonicalJson = (value: unknown): string => {
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (typeof value === "object" && value !== null) {
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
throw new Error("Expected a plain JSON object");
return `{${Object.entries(value)
.filter(([, entry]) => entry !== undefined)
.sort(([a], [b]) => compareText(a, b))
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
.join(",")}}`;
}
throw new Error(`Cannot hash non-JSON value: ${typeof value}`);
};
export const contentDigest = (value: unknown) =>
`sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
const semantic = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(semantic);
if (value && typeof value === "object")
return Object.fromEntries(
Object.entries(value)
.filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation")
// These are authored data/maps, not schema nodes. A user field literally
// named displayName or documentation is semantic and must stay in the hash.
.map(([key, entry]) => [key, key === "defaultValue" || key === "fields" ? entry : semantic(entry)]),
);
return value;
};
const sorted = <T>(entries: readonly T[], key: (entry: T) => string) =>
[...entries].sort((a, b) => compareText(key(a), key(b)));
export const conformanceIdentity = (entry: Conformance): string =>
entry.id ?? `legacy:${entry.atomId}:${entry.interfaceRevisionId}`;
export type StorageContract = {
id: string;
ownerId: string;
kind: "state" | "edge";
digest: string;
definition: unknown;
};
/** Automatic evolution preserves values; it never interprets migration code or
* guesses that a new nominal message descriptor means the same representation. */
export const storageChangeRequiresMigration = (
previous: StorageContract | undefined,
next: StorageContract | undefined,
oldAtomIds: ReadonlySet<string>,
): boolean => {
if (!next) return true;
const after = next.definition as PersistentAttachment;
if (!previous) {
if (after.kind === "state")
return (
oldAtomIds.has(after.attachedTo) && after.defaultValue === undefined && after.valueType.kind !== "optional"
);
return after.endpoints.some(
(endpoint) =>
endpoint.cardinality === "exactly-one" &&
(endpoint.constraint.kind !== "atom" || oldAtomIds.has(endpoint.constraint.atomId)),
);
}
if (previous.ownerId !== next.ownerId || previous.kind !== next.kind) return true;
const before = previous.definition as PersistentAttachment;
if (before.kind === "state" && after.kind === "state") {
// Capture materializes old defaults, so changing a default affects only
// newly constructed objects, not existing sparse state.
const { defaultValue: _beforeDefault, ...beforeStorage } = before;
const { defaultValue: _afterDefault, ...afterStorage } = after;
return canonicalJson(beforeStorage) !== canonicalJson(afterStorage);
}
return canonicalJson(before) !== canonicalJson(after);
};
export const storageContracts = (workspace: WorkspaceRevision): StorageContract[] => {
const result: StorageContract[] = [];
const add = (attachment: PersistentAttachment, ownerId: string) => {
const definition = semantic(attachment);
result.push({
id: attachment.id,
ownerId,
kind: attachment.kind,
definition,
digest: contentDigest({ ownerId, definition }),
});
};
for (const attachment of workspace.sharedAttachments) add(attachment, "legacy:workspace");
for (const conformance of workspace.conformances)
for (const attachment of conformance.privateAttachments) add(attachment, conformanceIdentity(conformance));
return sorted(result, (entry) => entry.id);
};
type GraphNode = { value: unknown; dependencies: Set<string>; reviewProviders: Set<string> };
export type RuntimeContract = {
groupId: string;
packageId: string;
packageRevisionId: string;
digest: string;
reviewProviders: string[];
dependencies: Array<{ id: string; digest: string }>;
};
/** Build only outbound execution dependencies. Incoming callers never retain or invalidate a provider. */
export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[] => {
const nodes = new Map<string, GraphNode>();
const node = (key: string, value: unknown) => {
const result = { value: semantic(value), dependencies: new Set<string>(), reviewProviders: new Set<string>() };
nodes.set(key, result);
return result;
};
const conformanceKey = (atom: string, iface: string) => `conformance:${atom}:${iface}`;
for (const storage of storageContracts(workspace)) node(`attachment:${storage.id}`, storage);
for (const iface of workspace.interfaceImports)
node(`interface:${iface.revisionId}`, {
...iface,
members: sorted(iface.members, (entry) => entry.id).map((entry) => ({
...entry,
operations: sorted(entry.operations, (operation) => operation.id),
})),
});
for (const pkg of workspace.packageImports)
node(`package:${pkg.revisionId}`, {
...pkg,
semanticMajor: pkg.semanticMajor ?? 1,
exports: sorted(pkg.exports, (entry) => entry.id).map((entry) => ({
...entry,
dependencyPorts: sorted(entry.dependencyPorts, (port) => port.id),
})),
});
const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set<string>) => {
if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`);
if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`);
if (binding.kind === "constructor") {
parent.dependencies.add(`constructor:${binding.atomId}`);
const ctor = workspace.constructors.find((entry) => entry.atomId === binding.atomId);
const pkg = workspace.packageImports.find((entry) => entry.revisionId === ctor?.packageRevisionId);
if (pkg) reviews?.add(pkg.packageId);
}
if (binding.kind !== "constructor" && binding.via) parent.dependencies.add(`attachment:${binding.via.edgeTypeId}`);
if (binding.kind === "interface") {
parent.dependencies.add(`interface:${binding.interfaceRevisionId}`);
// An edge traversal may select any matching target. Conservatively include every possible witness.
for (const conformance of workspace.conformances) {
if (
conformance.interfaceRevisionId === binding.interfaceRevisionId &&
(binding.via || conformance.atomId === atomId)
) {
parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
reviews?.add(conformanceIdentity(conformance));
for (const operation of conformance.operationBindings) {
if (operation.binding.kind !== "package") continue;
const revisionId = operation.binding.packageRevisionId;
const pkg = workspace.packageImports.find((entry) => entry.revisionId === revisionId);
if (pkg) reviews?.add(pkg.packageId);
}
}
}
}
};
const binding = (parent: GraphNode, value: Binding, atomId: string, context: unknown) => {
if (value.kind !== "package") {
dependency(parent, value, atomId);
return;
}
const packageNode = nodes.get(`package:${value.packageRevisionId}`)!;
parent.dependencies.add(`package:${value.packageRevisionId}`);
const normalized = { ...value, dependencies: sorted(value.dependencies, (entry) => entry.portId) };
const key = `binding:${contentDigest({ atomId, context, value: semantic(normalized) })}`;
const bound = node(key, { atomId, context, binding: normalized });
packageNode.dependencies.add(key);
for (const port of value.dependencies) dependency(bound, port.binding, atomId, packageNode.reviewProviders);
};
for (const conformance of workspace.conformances) {
const parent = node(conformanceKey(conformance.atomId, conformance.interfaceRevisionId), {
id: conformanceIdentity(conformance),
semanticMajor: conformance.semanticMajor ?? 1,
atomId: conformance.atomId,
interfaceRevisionId: conformance.interfaceRevisionId,
operations: sorted(conformance.operationBindings, (entry) => entry.operationId),
materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId),
});
parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`);
const contract = workspace.interfaceImports.find((entry) => entry.revisionId === conformance.interfaceRevisionId);
for (const required of contract?.requiredInterfaces ?? [])
parent.dependencies.add(conformanceKey(conformance.atomId, required));
for (const attachment of conformance.privateAttachments) parent.dependencies.add(`attachment:${attachment.id}`);
for (const operation of conformance.operationBindings)
binding(parent, operation.binding, conformance.atomId, {
conformanceId: conformanceIdentity(conformance),
semanticMajor: conformance.semanticMajor ?? 1,
operationId: operation.operationId,
});
for (const materialization of conformance.relationshipMaterializations) {
parent.dependencies.add(`constructor:${materialization.constructorAtomId}`);
parent.dependencies.add(`attachment:${materialization.edgeTypeId}`);
}
}
for (const constructor of workspace.constructors) {
const parent = node(`constructor:${constructor.atomId}`, constructor);
binding(parent, { kind: "package", ...constructor }, constructor.atomId, { constructor: constructor.atomId });
}
const counts = new Map<string, number>();
for (const pkg of workspace.packageImports) counts.set(pkg.packageId, (counts.get(pkg.packageId) ?? 0) + 1);
return sorted(
workspace.packageImports.map((pkg): RuntimeContract => {
const visited = new Set<string>();
const walk = (key: string) => {
if (visited.has(key)) return;
const entry = nodes.get(key);
if (!entry) throw new Error(`Unresolved execution dependency ${key}`);
visited.add(key);
for (const target of entry.dependencies) walk(target);
};
walk(`package:${pkg.revisionId}`);
const dependencies = [...visited].sort().map((id) => ({ id, digest: contentDigest(nodes.get(id)!.value) }));
return {
groupId: counts.get(pkg.packageId) === 1 ? pkg.packageId : `${pkg.packageId}#${pkg.revisionId}`,
packageId: pkg.packageId,
packageRevisionId: pkg.revisionId,
digest: contentDigest(dependencies),
reviewProviders: [...nodes.get(`package:${pkg.revisionId}`)!.reviewProviders].sort(),
dependencies,
};
}),
(entry) => entry.groupId,
);
};
export type EvolutionReview = {
requirementDigest: string;
decision: "changed" | "accepted-unchanged";
rationale: string;
agentId: string;
};
export type ReviewRequirement = {
consumerId: string;
providerId: string;
oldMajor: number;
newMajor: number;
requirementDigest: string;
};
export type RuntimeAction = {
groupId: string;
action: "keep" | "start" | "replace" | "retire";
previous?: RuntimeContract;
candidate?: RuntimeContract;
reasons: string[];
};
export type EvolutionReport = {
schemaVersion: 1;
baselineDigest: string | null;
candidateDigest: string;
checkerVersion: string;
runtimeActions: RuntimeAction[];
storageChanges: Array<{
id: string;
kind: "add" | "remove" | "change";
requiresMigration: boolean;
previous?: StorageContract;
candidate?: StorageContract;
}>;
migrationRequired: string[];
reviews: Array<ReviewRequirement & { accepted: boolean }>;
packageChecks: Array<{ groupId: string; contractDigest: string }>;
blockers: string[];
};
export const planEvolution = (
baseline: WorkspaceRevision | null,
candidate: WorkspaceRevision,
options: { reviews?: EvolutionReview[]; allowLegacy?: boolean } = {},
): EvolutionReport => {
const issues = validateWorkspaceRevision(candidate);
if (issues.length)
throw new Error(
`Invalid candidate workspace:\n${issues.map((entry) => `${entry.path}: ${entry.message}`).join("\n")}`,
);
if (baseline && baseline.workspaceId !== candidate.workspaceId)
throw new Error("Cannot evolve a different workspace");
const candidateDigest = contentDigest(candidate);
const checkerVersion = "quixos-evolution-v1";
const blockers: string[] = [];
if (!options.allowLegacy) {
if (candidate.sharedAttachments.length)
blockers.push("Assign legacy workspace-shared attachments to explicit conformance owners");
for (const entry of candidate.conformances)
if (!entry.id)
blockers.push(`Conformance ${entry.atomId} as ${entry.interfaceRevisionId} requires an authored ID`);
}
const previousRuntimes = new Map((baseline ? runtimeContracts(baseline) : []).map((entry) => [entry.groupId, entry]));
const nextRuntimes = new Map(runtimeContracts(candidate).map((entry) => [entry.groupId, entry]));
const runtimeActions: RuntimeAction[] = [...new Set([...previousRuntimes.keys(), ...nextRuntimes.keys()])]
.sort()
.map((groupId) => {
const previous = previousRuntimes.get(groupId),
next = nextRuntimes.get(groupId);
const before = new Map(previous?.dependencies.map((entry) => [entry.id, entry.digest]));
const after = new Map(next?.dependencies.map((entry) => [entry.id, entry.digest]));
const reasons = [...new Set([...before.keys(), ...after.keys()])]
.sort()
.filter((id) => before.get(id) !== after.get(id));
return {
groupId,
action: !previous ? "start" : !next ? "retire" : previous.digest === next.digest ? "keep" : "replace",
...(previous ? { previous } : {}),
...(next ? { candidate: next } : {}),
reasons,
};
});
const beforeStorage = new Map((baseline ? storageContracts(baseline) : []).map((entry) => [entry.id, entry]));
const afterStorage = new Map(storageContracts(candidate).map((entry) => [entry.id, entry]));
const storageChanges: EvolutionReport["storageChanges"] = [];
for (const id of [...new Set([...beforeStorage.keys(), ...afterStorage.keys()])].sort()) {
const previous = beforeStorage.get(id),
next = afterStorage.get(id);
if (previous?.digest !== next?.digest)
storageChanges.push({
id,
kind: !previous ? "add" : !next ? "remove" : "change",
requiresMigration: storageChangeRequiresMigration(
previous,
next,
new Set(baseline?.atoms.map((atom) => atom.id) ?? []),
),
...(previous ? { previous } : {}),
...(next ? { candidate: next } : {}),
});
}
const providers = (workspace: WorkspaceRevision) => [
...workspace.packageImports.map((entry) => ({
id: entry.packageId as string,
revision: entry.revisionId as string,
major: entry.semanticMajor ?? 1,
node: `package:${entry.revisionId}`,
digest: contentDigest(entry),
})),
...workspace.conformances.map((entry) => ({
id: conformanceIdentity(entry),
revision: contentDigest(entry),
major: entry.semanticMajor ?? 1,
node: `conformance:${entry.atomId}:${entry.interfaceRevisionId}`,
digest: contentDigest(entry),
})),
];
const oldProviders = baseline ? providers(baseline) : [];
const reviews: EvolutionReport["reviews"] = [];
for (const provider of providers(candidate)) {
const old = oldProviders.filter((entry) => entry.id === provider.id);
if (old.length > 1) {
blockers.push(`Ambiguous semantic-major lineage for ${provider.id}`);
continue;
}
if (!old[0] || old[0].major === provider.major) continue;
if (provider.major < old[0].major) blockers.push(`Semantic major decreases for ${provider.id}`);
for (const consumer of nextRuntimes.values()) {
if (consumer.packageId === provider.id || !consumer.reviewProviders.includes(provider.id)) continue;
const requirement = {
consumerId: consumer.groupId,
providerId: provider.id,
oldMajor: old[0].major,
newMajor: provider.major,
};
const requirementDigest = contentDigest({
...requirement,
oldProvider: old[0].digest,
newProvider: provider.digest,
consumer: consumer.digest,
checkerVersion,
});
const accepted = (options.reviews ?? []).some(
(entry) =>
entry.requirementDigest === requirementDigest &&
["changed", "accepted-unchanged"].includes(entry.decision) &&
entry.rationale.trim() &&
entry.agentId.trim(),
);
reviews.push({ ...requirement, requirementDigest, accepted });
if (!accepted) blockers.push(`Semantic-major review required: ${consumer.groupId} consumes ${provider.id}`);
}
}
return {
schemaVersion: 1,
baselineDigest: baseline ? contentDigest(baseline) : null,
candidateDigest,
checkerVersion,
runtimeActions,
storageChanges,
migrationRequired: storageChanges.filter((entry) => entry.requiresMigration).map((entry) => entry.id),
reviews,
packageChecks: runtimeActions
.filter((entry) => entry.candidate && entry.action !== "keep")
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })),
blockers,
};
};