155 lines
8.5 KiB
TypeScript
155 lines
8.5 KiB
TypeScript
import type { ErrorCode } from "./error";
|
|
|
|
export const CAPABILITY_CONTRACT_SCHEMA_VERSION = 1 as const;
|
|
export const CAPABILITY_CONTRACT_ID = "workflow:freeze_capability_error_budget_contract" as const;
|
|
export const CAPABILITY_CONTRACT_ERROR_CODES = [
|
|
"PROTOCOL_MISMATCH",
|
|
"REVISION_CONFLICT",
|
|
"OPEN_CANCELLED",
|
|
"CAPABILITY_CONTRACT_INVALID",
|
|
"CAPABILITY_CONTRACT_BUDGET_EXCEEDED",
|
|
"CAPABILITY_CONTRACT_UNSUPPORTED",
|
|
"CAPABILITY_CONTRACT_SCHEMA_HASH_MISMATCH",
|
|
"CAPABILITY_CONTRACT_MIGRATION_BLOCKED",
|
|
] as const satisfies readonly ErrorCode[];
|
|
|
|
export type CapabilityContractErrorCode = typeof CAPABILITY_CONTRACT_ERROR_CODES[number];
|
|
|
|
export interface CapabilityContractError {
|
|
code: CapabilityContractErrorCode;
|
|
severity: "info" | "warning" | "error" | "fatal";
|
|
recoverable: boolean;
|
|
}
|
|
|
|
export interface CapabilityContractIR {
|
|
schemaVersion: typeof CAPABILITY_CONTRACT_SCHEMA_VERSION;
|
|
contractId: typeof CAPABILITY_CONTRACT_ID;
|
|
capability: { level: "L0" | "L1" | "L2"; status: "BOUNDED" | "READY" | "BLOCKED" };
|
|
revision: { base: number; current: number };
|
|
budget: { maxErrors: number; maxMessageBytes: number; maxUnknownFields: number };
|
|
errors: CapabilityContractError[];
|
|
cancellation: { code: "OPEN_CANCELLED"; preservesRevision: boolean };
|
|
migration: { currentSchemaVersion: 1; supportedSchemaVersions: number[]; preserveUnknown: boolean; strategy: "RETAIN_UNKNOWN" };
|
|
schemaHash: string;
|
|
extensions?: Record<string, unknown>;
|
|
unknownFields?: Record<string, unknown>;
|
|
}
|
|
|
|
export class CapabilityContractValidationError extends Error {
|
|
readonly code: CapabilityContractErrorCode;
|
|
|
|
constructor(code: CapabilityContractErrorCode, message: string) {
|
|
super(`${code}: ${message}`);
|
|
this.name = "CapabilityContractValidationError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function record(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function integer(value: unknown, name: string, minimum: number, maximum: number): number {
|
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_INVALID", `${name} is outside its bounded range`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function text(value: unknown, name: string, maximum: number): string {
|
|
if (typeof value !== "string" || value.length === 0 || value.length > maximum) {
|
|
throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_INVALID", `${name} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function digest(value: unknown, name: string): string {
|
|
const result = text(value, name, 64);
|
|
if (!/^[a-f0-9]{64}$/u.test(result)) {
|
|
throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_SCHEMA_HASH_MISMATCH", `${name} is not a SHA-256 digest`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function parseCapabilityContract(value: unknown): CapabilityContractIR {
|
|
if (!record(value)) throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_INVALID", "contract must be an object");
|
|
const input = value;
|
|
if (input.schemaVersion !== CAPABILITY_CONTRACT_SCHEMA_VERSION) {
|
|
throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_UNSUPPORTED", "unsupported schema version");
|
|
}
|
|
if (input.contractId !== CAPABILITY_CONTRACT_ID) {
|
|
throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_INVALID", "contractId does not identify this workflow");
|
|
}
|
|
if (!record(input.capability) || !["L0", "L1", "L2"].includes(input.capability.level as string) || !["BOUNDED", "READY", "BLOCKED"].includes(input.capability.status as string)) {
|
|
throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_INVALID", "capability is invalid");
|
|
}
|
|
if (!record(input.revision)) throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_INVALID", "revision is invalid");
|
|
const revision = {
|
|
base: integer(input.revision.base, "revision.base", 0, Number.MAX_SAFE_INTEGER),
|
|
current: integer(input.revision.current, "revision.current", 0, Number.MAX_SAFE_INTEGER),
|
|
};
|
|
if (revision.current < revision.base) throw new CapabilityContractValidationError("REVISION_CONFLICT", "current revision precedes base revision");
|
|
if (!record(input.budget)) throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_INVALID", "budget is invalid");
|
|
const budget = {
|
|
maxErrors: integer(input.budget.maxErrors, "budget.maxErrors", 1, 128),
|
|
maxMessageBytes: integer(input.budget.maxMessageBytes, "budget.maxMessageBytes", 32, 4096),
|
|
maxUnknownFields: integer(input.budget.maxUnknownFields, "budget.maxUnknownFields", 0, 128),
|
|
};
|
|
if (!Array.isArray(input.errors) || input.errors.length === 0 || input.errors.length > budget.maxErrors) {
|
|
throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_BUDGET_EXCEEDED", "errors exceed the declared budget");
|
|
}
|
|
const errorCodes = new Set<string>();
|
|
const errors = input.errors.map((entry, index) => {
|
|
if (!record(entry) || !CAPABILITY_CONTRACT_ERROR_CODES.includes(entry.code as CapabilityContractErrorCode) || errorCodes.has(entry.code as string)) {
|
|
throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_INVALID", `errors[${index}] is invalid or duplicated`);
|
|
}
|
|
errorCodes.add(entry.code as string);
|
|
if (!["info", "warning", "error", "fatal"].includes(entry.severity as string) || typeof entry.recoverable !== "boolean") {
|
|
throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_INVALID", `errors[${index}] has invalid severity or recovery policy`);
|
|
}
|
|
return { code: entry.code as CapabilityContractErrorCode, severity: entry.severity as CapabilityContractError["severity"], recoverable: entry.recoverable };
|
|
});
|
|
if (!record(input.cancellation) || input.cancellation.code !== "OPEN_CANCELLED" || typeof input.cancellation.preservesRevision !== "boolean") {
|
|
throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_INVALID", "cancellation policy is invalid");
|
|
}
|
|
if (!record(input.migration) || input.migration.currentSchemaVersion !== 1 || !Array.isArray(input.migration.supportedSchemaVersions) || !input.migration.supportedSchemaVersions.includes(1) || input.migration.preserveUnknown !== true || input.migration.strategy !== "RETAIN_UNKNOWN") {
|
|
throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_MIGRATION_BLOCKED", "migration policy must retain unknown data");
|
|
}
|
|
const schemaHash = digest(input.schemaHash, "schemaHash");
|
|
if (input.extensions !== undefined && !record(input.extensions)) throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_INVALID", "extensions must be an object");
|
|
const extensions = input.extensions as Record<string, unknown> | undefined;
|
|
const knownFields = new Set(["schemaVersion", "contractId", "capability", "revision", "budget", "errors", "cancellation", "migration", "schemaHash", "extensions"]);
|
|
const unknownFields = Object.fromEntries(Object.entries(input).filter(([key]) => !knownFields.has(key)));
|
|
if (Object.keys(unknownFields).length > budget.maxUnknownFields) throw new CapabilityContractValidationError("CAPABILITY_CONTRACT_BUDGET_EXCEEDED", "unknown fields exceed the migration budget");
|
|
return {
|
|
schemaVersion: 1,
|
|
contractId: CAPABILITY_CONTRACT_ID,
|
|
capability: { level: input.capability.level as "L0" | "L1" | "L2", status: input.capability.status as "BOUNDED" | "READY" | "BLOCKED" },
|
|
revision,
|
|
budget,
|
|
errors,
|
|
cancellation: { code: "OPEN_CANCELLED", preservesRevision: input.cancellation.preservesRevision },
|
|
migration: { currentSchemaVersion: 1, supportedSchemaVersions: input.migration.supportedSchemaVersions.map((value) => integer(value, "migration.supportedSchemaVersions[]", 1, 1)), preserveUnknown: true, strategy: "RETAIN_UNKNOWN" },
|
|
schemaHash,
|
|
...(extensions === undefined ? {} : { extensions }),
|
|
...(Object.keys(unknownFields).length === 0 ? {} : { unknownFields }),
|
|
};
|
|
}
|
|
|
|
function stable(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(stable);
|
|
if (!record(value)) return value;
|
|
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
|
}
|
|
|
|
/** Canonical bytes covered by schemaHash; unknown extension data is retained. */
|
|
export function capabilityContractSchemaInput(value: CapabilityContractIR): string {
|
|
const { schemaHash: _schemaHash, unknownFields: _unknownFields, ...payload } = value;
|
|
return JSON.stringify(stable(payload));
|
|
}
|
|
|
|
/** Version-1 migration is intentionally lossless for unknown root fields/extensions. */
|
|
export function migrateCapabilityContract(value: unknown): CapabilityContractIR {
|
|
return parseCapabilityContract(value);
|
|
}
|