2026-09-10 18:27:41 -07:00
|
|
|
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. */
|
2026-09-15 15:23:24 -07:00
|
|
|
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
2026-09-10 18:27:41 -07:00
|
|
|
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) {
|
2026-09-15 15:23:24 -07:00
|
|
|
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(",")}}`;
|
2026-09-10 18:27:41 -07:00
|
|
|
}
|
|
|
|
|
throw new Error(`Cannot hash non-JSON value: ${typeof value}`);
|
|
|
|
|
};
|
2026-09-15 15:23:24 -07:00
|
|
|
export const contentDigest = (value: unknown) =>
|
|
|
|
|
`sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
2026-09-10 18:27:41 -07:00
|
|
|
const semantic = (value: unknown): unknown => {
|
|
|
|
|
if (Array.isArray(value)) return value.map(semantic);
|
2026-09-15 15:23:24 -07:00
|
|
|
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)]),
|
|
|
|
|
);
|
2026-09-10 18:27:41 -07:00
|
|
|
return value;
|
|
|
|
|
};
|
2026-09-15 15:23:24 -07:00
|
|
|
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}`;
|
2026-09-10 18:27:41 -07:00
|
|
|
|
2026-09-15 15:23:24 -07:00
|
|
|
export type StorageContract = {
|
|
|
|
|
id: string;
|
|
|
|
|
ownerId: string;
|
|
|
|
|
kind: "state" | "edge";
|
|
|
|
|
digest: string;
|
|
|
|
|
definition: unknown;
|
|
|
|
|
};
|
2026-09-14 15:24:37 -07:00
|
|
|
/** Automatic evolution preserves values; it never interprets migration code or
|
|
|
|
|
* guesses that a new nominal message descriptor means the same representation. */
|
2026-09-15 15:23:24 -07:00
|
|
|
export const storageChangeRequiresMigration = (
|
|
|
|
|
previous: StorageContract | undefined,
|
|
|
|
|
next: StorageContract | undefined,
|
|
|
|
|
oldAtomIds: ReadonlySet<string>,
|
|
|
|
|
): boolean => {
|
2026-09-14 15:24:37 -07:00
|
|
|
if (!next) return true;
|
|
|
|
|
const after = next.definition as PersistentAttachment;
|
|
|
|
|
if (!previous) {
|
2026-09-15 15:23:24 -07:00
|
|
|
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)),
|
|
|
|
|
);
|
2026-09-14 15:24:37 -07:00
|
|
|
}
|
|
|
|
|
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.
|
2026-09-15 15:23:24 -07:00
|
|
|
const { defaultValue: _beforeDefault, ...beforeStorage } = before;
|
|
|
|
|
const { defaultValue: _afterDefault, ...afterStorage } = after;
|
2026-09-14 15:24:37 -07:00
|
|
|
return canonicalJson(beforeStorage) !== canonicalJson(afterStorage);
|
|
|
|
|
}
|
|
|
|
|
return canonicalJson(before) !== canonicalJson(after);
|
|
|
|
|
};
|
2026-09-10 18:27:41 -07:00
|
|
|
export const storageContracts = (workspace: WorkspaceRevision): StorageContract[] => {
|
|
|
|
|
const result: StorageContract[] = [];
|
|
|
|
|
const add = (attachment: PersistentAttachment, ownerId: string) => {
|
|
|
|
|
const definition = semantic(attachment);
|
2026-09-15 15:23:24 -07:00
|
|
|
result.push({
|
|
|
|
|
id: attachment.id,
|
|
|
|
|
ownerId,
|
|
|
|
|
kind: attachment.kind,
|
|
|
|
|
definition,
|
|
|
|
|
digest: contentDigest({ ownerId, definition }),
|
|
|
|
|
});
|
2026-09-10 18:27:41 -07:00
|
|
|
};
|
|
|
|
|
for (const attachment of workspace.sharedAttachments) add(attachment, "legacy:workspace");
|
2026-09-15 15:23:24 -07:00
|
|
|
for (const conformance of workspace.conformances)
|
|
|
|
|
for (const attachment of conformance.privateAttachments) add(attachment, conformanceIdentity(conformance));
|
2026-09-10 18:27:41 -07:00
|
|
|
return sorted(result, (entry) => entry.id);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type GraphNode = { value: unknown; dependencies: Set<string>; reviewProviders: Set<string> };
|
2026-09-15 15:23:24 -07:00
|
|
|
export type RuntimeContract = {
|
|
|
|
|
groupId: string;
|
|
|
|
|
packageId: string;
|
|
|
|
|
packageRevisionId: string;
|
|
|
|
|
digest: string;
|
|
|
|
|
reviewProviders: string[];
|
|
|
|
|
dependencies: Array<{ id: string; digest: string }>;
|
|
|
|
|
};
|
2026-09-10 18:27:41 -07:00
|
|
|
|
|
|
|
|
/** 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);
|
2026-09-15 15:23:24 -07:00
|
|
|
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),
|
|
|
|
|
})),
|
|
|
|
|
});
|
2026-09-10 18:27:41 -07:00
|
|
|
const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set<string>) => {
|
2026-09-17 14:34:16 -07:00
|
|
|
if (binding.kind === "query") {
|
|
|
|
|
const pkg = workspace.packageImports.find((pkg) => pkg.revisionId === binding.packageRevisionId);
|
|
|
|
|
const query = pkg?.checkedQueries?.find((query) => query.declaration.id === binding.queryId);
|
|
|
|
|
if (!query) throw new Error(`Missing checked query contract ${binding.packageRevisionId}:${binding.queryId}`);
|
|
|
|
|
const key = `query:${binding.packageRevisionId}:${binding.queryId}`;
|
|
|
|
|
const queryNode = nodes.get(key) ?? node(key, query);
|
|
|
|
|
parent.dependencies.add(key);
|
|
|
|
|
for (const effect of query.effects) {
|
|
|
|
|
queryNode.dependencies.add(`interface:${effect.interfaceRevisionId}`);
|
|
|
|
|
for (const conformance of workspace.conformances.filter(
|
|
|
|
|
(entry) => entry.interfaceRevisionId === effect.interfaceRevisionId,
|
|
|
|
|
))
|
|
|
|
|
queryNode.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-10 18:27:41 -07:00
|
|
|
if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`);
|
|
|
|
|
if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`);
|
2026-09-19 22:09:55 -07:00
|
|
|
if (binding.kind === "creation") {
|
|
|
|
|
for (const owner of binding.owners)
|
|
|
|
|
parent.dependencies.add(conformanceKey(binding.atomId, owner.interfaceRevisionId));
|
|
|
|
|
}
|
2026-09-10 18:27:41 -07:00
|
|
|
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) {
|
2026-09-15 15:23:24 -07:00
|
|
|
if (
|
|
|
|
|
conformance.interfaceRevisionId === binding.interfaceRevisionId &&
|
|
|
|
|
(binding.via || conformance.atomId === atomId)
|
|
|
|
|
) {
|
2026-09-10 18:27:41 -07:00
|
|
|
parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
|
|
|
|
|
reviews?.add(conformanceIdentity(conformance));
|
|
|
|
|
for (const operation of conformance.operationBindings) {
|
2026-09-18 16:59:19 -07:00
|
|
|
let provider: Binding | undefined = operation.binding;
|
|
|
|
|
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;
|
2026-09-10 18:27:41 -07:00
|
|
|
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) => {
|
2026-09-20 22:41:51 -07:00
|
|
|
if (value.kind === "lifecycle") return;
|
2026-09-15 15:23:24 -07:00
|
|
|
if (value.kind !== "package") {
|
|
|
|
|
dependency(parent, value, atomId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-10 18:27:41 -07:00
|
|
|
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), {
|
2026-09-15 15:23:24 -07:00
|
|
|
id: conformanceIdentity(conformance),
|
|
|
|
|
semanticMajor: conformance.semanticMajor ?? 1,
|
|
|
|
|
atomId: conformance.atomId,
|
|
|
|
|
interfaceRevisionId: conformance.interfaceRevisionId,
|
2026-09-10 18:27:41 -07:00
|
|
|
operations: sorted(conformance.operationBindings, (entry) => entry.operationId),
|
2026-09-19 22:09:55 -07:00
|
|
|
...(conformance.initialization
|
|
|
|
|
? { initialization: sorted(conformance.initialization, (entry) => entry.input) }
|
|
|
|
|
: {}),
|
2026-09-10 18:27:41 -07:00
|
|
|
materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId),
|
|
|
|
|
});
|
|
|
|
|
parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`);
|
2026-09-16 00:12:39 -07:00
|
|
|
const contract = workspace.interfaceImports.find((entry) => entry.revisionId === conformance.interfaceRevisionId);
|
|
|
|
|
for (const required of contract?.requiredInterfaces ?? [])
|
|
|
|
|
parent.dependencies.add(conformanceKey(conformance.atomId, required));
|
2026-09-10 18:27:41 -07:00
|
|
|
for (const attachment of conformance.privateAttachments) parent.dependencies.add(`attachment:${attachment.id}`);
|
2026-09-15 15:23:24 -07:00
|
|
|
for (const operation of conformance.operationBindings)
|
|
|
|
|
binding(parent, operation.binding, conformance.atomId, {
|
|
|
|
|
conformanceId: conformanceIdentity(conformance),
|
|
|
|
|
semanticMajor: conformance.semanticMajor ?? 1,
|
|
|
|
|
operationId: operation.operationId,
|
|
|
|
|
});
|
2026-09-10 18:27:41 -07:00
|
|
|
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);
|
2026-09-15 15:23:24 -07:00
|
|
|
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,
|
|
|
|
|
);
|
2026-09-10 18:27:41 -07:00
|
|
|
};
|
|
|
|
|
|
2026-09-15 15:23:24 -07:00
|
|
|
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[];
|
|
|
|
|
};
|
2026-09-10 18:27:41 -07:00
|
|
|
export type EvolutionReport = {
|
2026-09-15 15:23:24 -07:00
|
|
|
schemaVersion: 1;
|
|
|
|
|
baselineDigest: string | null;
|
|
|
|
|
candidateDigest: string;
|
|
|
|
|
checkerVersion: string;
|
2026-09-10 18:27:41 -07:00
|
|
|
runtimeActions: RuntimeAction[];
|
2026-09-15 15:23:24 -07:00
|
|
|
storageChanges: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
kind: "add" | "remove" | "change";
|
|
|
|
|
requiresMigration: boolean;
|
|
|
|
|
previous?: StorageContract;
|
|
|
|
|
candidate?: StorageContract;
|
|
|
|
|
}>;
|
2026-09-14 15:24:37 -07:00
|
|
|
migrationRequired: string[];
|
2026-09-10 18:27:41 -07:00
|
|
|
reviews: Array<ReviewRequirement & { accepted: boolean }>;
|
|
|
|
|
packageChecks: Array<{ groupId: string; contractDigest: string }>;
|
|
|
|
|
blockers: string[];
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-15 15:23:24 -07:00
|
|
|
export const planEvolution = (
|
|
|
|
|
baseline: WorkspaceRevision | null,
|
|
|
|
|
candidate: WorkspaceRevision,
|
|
|
|
|
options: { reviews?: EvolutionReview[]; allowLegacy?: boolean } = {},
|
|
|
|
|
): EvolutionReport => {
|
2026-09-10 18:27:41 -07:00
|
|
|
const issues = validateWorkspaceRevision(candidate);
|
2026-09-15 15:23:24 -07:00
|
|
|
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");
|
2026-09-10 18:27:41 -07:00
|
|
|
const candidateDigest = contentDigest(candidate);
|
|
|
|
|
const checkerVersion = "quixos-evolution-v1";
|
|
|
|
|
const blockers: string[] = [];
|
|
|
|
|
if (!options.allowLegacy) {
|
2026-09-15 15:23:24 -07:00
|
|
|
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`);
|
2026-09-10 18:27:41 -07:00
|
|
|
}
|
|
|
|
|
const previousRuntimes = new Map((baseline ? runtimeContracts(baseline) : []).map((entry) => [entry.groupId, entry]));
|
|
|
|
|
const nextRuntimes = new Map(runtimeContracts(candidate).map((entry) => [entry.groupId, entry]));
|
2026-09-15 15:23:24 -07:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-09-10 18:27:41 -07:00
|
|
|
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()) {
|
2026-09-15 15:23:24 -07:00
|
|
|
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 } : {}),
|
|
|
|
|
});
|
2026-09-10 18:27:41 -07:00
|
|
|
}
|
2026-09-21 01:30:57 -07:00
|
|
|
const semanticReview = assessSemanticReviews(baseline, candidate, options.reviews ?? [], checkerVersion, [
|
|
|
|
|
...nextRuntimes.values(),
|
|
|
|
|
]);
|
|
|
|
|
const reviews = semanticReview.reviews;
|
|
|
|
|
blockers.push(...semanticReview.blockers);
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Review dependency contracts, without making any storage or process-placement decision. */
|
|
|
|
|
function assessSemanticReviews(
|
|
|
|
|
baseline: WorkspaceRevision | null,
|
|
|
|
|
candidate: WorkspaceRevision,
|
|
|
|
|
receipts: readonly EvolutionReview[],
|
|
|
|
|
checkerVersion: string,
|
|
|
|
|
consumers: readonly RuntimeContract[],
|
|
|
|
|
): Pick<EvolutionReport, "reviews" | "blockers"> {
|
|
|
|
|
const blockers: string[] = [];
|
2026-09-10 18:27:41 -07:00
|
|
|
const providers = (workspace: WorkspaceRevision) => [
|
2026-09-15 15:23:24 -07:00
|
|
|
...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),
|
|
|
|
|
})),
|
2026-09-10 18:27:41 -07:00
|
|
|
];
|
|
|
|
|
const oldProviders = baseline ? providers(baseline) : [];
|
|
|
|
|
const reviews: EvolutionReport["reviews"] = [];
|
|
|
|
|
for (const provider of providers(candidate)) {
|
|
|
|
|
const old = oldProviders.filter((entry) => entry.id === provider.id);
|
2026-09-15 15:23:24 -07:00
|
|
|
if (old.length > 1) {
|
|
|
|
|
blockers.push(`Ambiguous semantic-major lineage for ${provider.id}`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-09-10 18:27:41 -07:00
|
|
|
if (!old[0] || old[0].major === provider.major) continue;
|
|
|
|
|
if (provider.major < old[0].major) blockers.push(`Semantic major decreases for ${provider.id}`);
|
2026-09-21 01:30:57 -07:00
|
|
|
for (const consumer of consumers) {
|
2026-09-10 18:27:41 -07:00
|
|
|
if (consumer.packageId === provider.id || !consumer.reviewProviders.includes(provider.id)) continue;
|
2026-09-15 15:23:24 -07:00
|
|
|
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,
|
|
|
|
|
});
|
2026-09-21 01:30:57 -07:00
|
|
|
const accepted = receipts.some(
|
2026-09-15 15:23:24 -07:00
|
|
|
(entry) =>
|
|
|
|
|
entry.requirementDigest === requirementDigest &&
|
|
|
|
|
["changed", "accepted-unchanged"].includes(entry.decision) &&
|
|
|
|
|
entry.rationale.trim() &&
|
|
|
|
|
entry.agentId.trim(),
|
|
|
|
|
);
|
2026-09-10 18:27:41 -07:00
|
|
|
reviews.push({ ...requirement, requirementDigest, accepted });
|
|
|
|
|
if (!accepted) blockers.push(`Semantic-major review required: ${consumer.groupId} consumes ${provider.id}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-21 01:30:57 -07:00
|
|
|
return { reviews, blockers };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function reviewSemanticMajors(
|
|
|
|
|
baseline: WorkspaceRevision | null,
|
|
|
|
|
candidate: WorkspaceRevision,
|
|
|
|
|
receipts: readonly EvolutionReview[] = [],
|
|
|
|
|
) {
|
|
|
|
|
for (const workspace of baseline ? [baseline, candidate] : [candidate]) {
|
|
|
|
|
const issues = validateWorkspaceRevision(workspace);
|
|
|
|
|
if (issues.length)
|
|
|
|
|
throw new Error("Invalid semantic-review workspace: " + issues.map((issue) => issue.message).join("; "));
|
|
|
|
|
}
|
|
|
|
|
if (baseline && baseline.workspaceId !== candidate.workspaceId)
|
|
|
|
|
throw new Error("Cannot review a different workspace");
|
|
|
|
|
const checkerVersion = "quixos-semantic-review-v1";
|
|
|
|
|
const report = assessSemanticReviews(baseline, candidate, receipts, checkerVersion, runtimeContracts(candidate));
|
2026-09-15 15:23:24 -07:00
|
|
|
return {
|
|
|
|
|
checkerVersion,
|
2026-09-21 01:30:57 -07:00
|
|
|
baselineDigest: baseline ? contentDigest(baseline) : null,
|
|
|
|
|
candidateDigest: contentDigest(candidate),
|
|
|
|
|
...report,
|
2026-09-15 15:23:24 -07:00
|
|
|
};
|
2026-09-21 01:30:57 -07:00
|
|
|
}
|