← Files OpsTruthARCHIVED FILE

scripts/validate-contracts.mjs

12.3 KB · Oct 2, 2026 · 00:31 UTC

↓ Download file

import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { basename, join } from "node:path";

const root = fileURLToPath(new URL("..", import.meta.url));
const contractRoot = join(root, "contracts");

function typeMatches(value, type) {
  if (type === "null") return value === null;
  if (type === "array") return Array.isArray(value);
  if (type === "object") return value !== null && typeof value === "object" && !Array.isArray(value);
  if (type === "integer") return Number.isInteger(value);
  return typeof value === type;
}

function resolveRef(rootSchema, ref) {
  if (!ref.startsWith("#/")) throw new Error(`unsupported external reference: ${ref}`);
  return ref.slice(2).split("/").reduce((value, part) => value?.[part.replaceAll("~1", "/").replaceAll("~0", "~")], rootSchema);
}

export function validateAgainstSchema(value, schema, rootSchema = schema, path = "$") {
  const errors = [];
  if (schema === true) return errors;
  if (schema === false) return [`${path}: value is prohibited`];
  if (schema.$ref) return validateAgainstSchema(value, resolveRef(rootSchema, schema.$ref), rootSchema, path);
  if (schema.oneOf) {
    const matches = schema.oneOf.filter((candidate) => validateAgainstSchema(value, candidate, rootSchema, path).length === 0).length;
    return matches === 1 ? [] : [`${path}: expected exactly one oneOf match, found ${matches}`];
  }
  if (schema.allOf) {
    for (const candidate of schema.allOf) errors.push(...validateAgainstSchema(value, candidate, rootSchema, path));
  }
  if (schema.if) {
    const conditionMatches = validateAgainstSchema(value, schema.if, rootSchema, path).length === 0;
    const branch = conditionMatches ? schema.then : schema.else;
    if (branch) errors.push(...validateAgainstSchema(value, branch, rootSchema, path));
  }
  if (Object.hasOwn(schema, "const") && JSON.stringify(value) !== JSON.stringify(schema.const)) errors.push(`${path}: const mismatch`);
  if (schema.enum && !schema.enum.some((candidate) => JSON.stringify(candidate) === JSON.stringify(value))) errors.push(`${path}: enum mismatch`);

  const acceptedTypes = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
  if (acceptedTypes.length && !acceptedTypes.some((type) => typeMatches(value, type))) {
    errors.push(`${path}: expected type ${acceptedTypes.join(" or ")}`);
    return errors;
  }

  if (typeof value === "string") {
    if (schema.minLength !== undefined && value.length < schema.minLength) errors.push(`${path}: shorter than minLength`);
    if (schema.maxLength !== undefined && value.length > schema.maxLength) errors.push(`${path}: longer than maxLength`);
    if (schema.pattern && !new RegExp(schema.pattern).test(value)) errors.push(`${path}: pattern mismatch`);
    if (schema.format === "date-time" && (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) || Number.isNaN(Date.parse(value)))) {
      errors.push(`${path}: invalid RFC 3339 UTC timestamp`);
    }
  }

  if (typeof value === "number") {
    if (schema.minimum !== undefined && value < schema.minimum) errors.push(`${path}: below minimum`);
    if (schema.maximum !== undefined && value > schema.maximum) errors.push(`${path}: above maximum`);
  }

  if (Array.isArray(value)) {
    if (schema.minItems !== undefined && value.length < schema.minItems) errors.push(`${path}: fewer than minItems`);
    if (schema.maxItems !== undefined && value.length > schema.maxItems) errors.push(`${path}: more than maxItems`);
    if (schema.uniqueItems && new Set(value.map((item) => JSON.stringify(item))).size !== value.length) errors.push(`${path}: duplicate array items`);
    if (schema.items) value.forEach((item, index) => errors.push(...validateAgainstSchema(item, schema.items, rootSchema, `${path}[${index}]`)));
  }

  if (value !== null && typeof value === "object" && !Array.isArray(value)) {
    for (const key of schema.required || []) if (!Object.hasOwn(value, key)) errors.push(`${path}.${key}: required property missing`);
    if (schema.additionalProperties === false) {
      for (const key of Object.keys(value)) if (!Object.hasOwn(schema.properties || {}, key)) errors.push(`${path}.${key}: unknown property`);
    }
    for (const [key, child] of Object.entries(schema.properties || {})) {
      if (Object.hasOwn(value, key)) errors.push(...validateAgainstSchema(value[key], child, rootSchema, `${path}.${key}`));
    }
  }
  return errors;
}

async function validateExamples() {
  const examplesRoot = join(contractRoot, "examples");
  const exampleNames = (await readdir(examplesRoot)).filter((name) => name.endsWith(".json")).sort();
  const failures = [];
  const examples = new Map();
  for (const name of exampleNames) {
    const example = JSON.parse(await readFile(join(examplesRoot, name), "utf8"));
    examples.set(example.schema, example);
    const schemaName = `${example.schema.slice("opstruth.".length)}.schema.json`;
    const schema = JSON.parse(await readFile(join(contractRoot, schemaName), "utf8"));
    for (const error of validateAgainstSchema(example, schema)) failures.push(`${name} ${error}`);
  }
  if (exampleNames.length !== 4) failures.push(`expected 4 structural examples, found ${exampleNames.length}`);
  failures.push(...semanticErrors(examples));
  if (failures.length) throw new Error(failures.join("\n"));
  return exampleNames.length;
}

async function validateEvidenceSchemas() {
  const expected = new Map([
    ["evidence-graph.schema.json", "urn:opstruth:schema:evidence-graph:1.0.0"],
    ["evidence-delta.schema.json", "urn:opstruth:schema:evidence-delta:1.0.0"],
  ]);
  const failures = [];
  for (const [name, id] of expected) {
    const schema = JSON.parse(await readFile(join(root, "schemas", name), "utf8"));
    if (schema.$id !== id) failures.push(`${name}: stable schema id mismatch`);
    if (schema.additionalProperties !== false) failures.push(`${name}: top-level unknown fields must fail closed`);
    if (schema.properties?.schemaVersion?.const !== "1.0.0") failures.push(`${name}: schema version mismatch`);
    if (!schema.required?.includes("digest")) failures.push(`${name}: canonical digest is required`);
  }
  if (failures.length) throw new Error(failures.join("\n"));
  return expected.size;
}

function semanticErrors(examples) {
  const errors = [];
  const request = examples.get("opstruth.action-request");
  const authorization = examples.get("opstruth.action-authorization");
  const receipt = examples.get("opstruth.execution-receipt");
  const result = examples.get("opstruth.verification-result");
  if (![request, authorization, receipt, result].every(Boolean)) return ["complete four-artifact example chain is required"];

  const permitted = new Set(request.permittedOperations);
  const forbidden = new Set(request.forbiddenOperations);
  for (const operation of permitted) if (forbidden.has(operation)) errors.push(`ActionRequest operation is both permitted and forbidden: ${operation}`);
  for (const operation of authorization.grantedOperations) if (!permitted.has(operation)) errors.push(`authorization expands request operation scope: ${operation}`);
  if (!request.approvalRequirement.allowedApproverIds.includes(authorization.approver.id)) errors.push("authorization approver is outside the request allowlist");
  if (authorization.decision === "APPROVED" && authorization.grantedOperations.length === 0) errors.push("approved authorization grants no operations");
  if (authorization.decision === "DENIED" && authorization.grantedOperations.length !== 0) errors.push("denied authorization grants operations");
  if (authorization.requestDigest !== request.digest) errors.push("authorization request digest does not bind ActionRequest");
  if (Date.parse(request.expiresAt) <= Date.parse(request.createdAt)) errors.push("ActionRequest expiry must follow creation");
  if (Date.parse(authorization.expiresAt) <= Date.parse(authorization.issuedAt)) errors.push("authorization expiry must follow issuance");
  if (Date.parse(authorization.expiresAt) > Date.parse(request.expiresAt)) errors.push("authorization expires after ActionRequest");

  if (receipt.requestDigest !== request.digest) errors.push("receipt request digest does not bind ActionRequest");
  if (receipt.authorizationDigest !== authorization.digest) errors.push("receipt authorization digest does not bind ActionAuthorization");
  if (receipt.idempotencyKey !== request.idempotencyKey) errors.push("receipt idempotency key mismatch");
  if (receipt.consumedAuthorizationNonce !== authorization.nonce) errors.push("receipt authorization nonce mismatch");
  if (Date.parse(receipt.completedAt) < Date.parse(receipt.startedAt)) errors.push("receipt completes before it starts");
  const granted = new Set(authorization.grantedOperations);
  for (const operation of receipt.operations) if (!granted.has(operation.type)) errors.push(`receipt operation was not granted: ${operation.type}`);
  const sequences = receipt.operations.map((operation) => operation.sequence);
  if (new Set(sequences).size !== sequences.length) errors.push("receipt operation sequence contains duplicates");
  if (sequences.some((sequence, index) => sequence !== index + 1)) errors.push("receipt operation sequence must be contiguous and ordered from one");

  if (result.requestDigest !== request.digest) errors.push("verification request digest mismatch");
  if (result.authorizationDigest !== authorization.digest) errors.push("verification authorization digest mismatch");
  if (result.receiptDigest !== receipt.digest) errors.push("verification receipt digest mismatch");
  if (result.subject.provider !== request.subject.provider || result.subject.repositoryId !== request.subject.repositoryId) errors.push("verification repository subject mismatch");
  if (result.subject.baselineCommitSha !== request.subject.baselineCommitSha) errors.push("verification baseline commit mismatch");
  if ((result.subject.environment || null) !== (request.subject.environment || null)) errors.push("verification environment mismatch");
  if (Date.parse(result.observedAt) < Date.parse(receipt.completedAt)) errors.push("post-execution verification predates receipt completion");
  const requestedAssertions = new Set(request.requestedOutcome.assertions.map((assertion) => assertion.assertionId));
  const resultAssertions = result.assertionResults.map((assertion) => assertion.assertionId);
  if (new Set(resultAssertions).size !== resultAssertions.length) errors.push("verification contains duplicate assertion results");
  for (const assertionId of requestedAssertions) if (!resultAssertions.includes(assertionId)) errors.push(`verification omits requested assertion: ${assertionId}`);
  for (const assertionId of resultAssertions) if (!requestedAssertions.has(assertionId)) errors.push(`verification adds unknown assertion: ${assertionId}`);
  const assertionVerdicts = result.assertionResults.map((assertion) => assertion.verdict);
  if (result.verdict === "VERIFIED" && (assertionVerdicts.some((verdict) => verdict !== "VERIFIED") || result.notVerified.length)) errors.push("VERIFIED result contains an unsatisfied assertion or proof gap");
  if (result.verdict === "PARTIAL" && (!assertionVerdicts.includes("VERIFIED") || assertionVerdicts.every((verdict) => verdict === "VERIFIED"))) errors.push("PARTIAL result must mix verified and non-verified assertions");
  if (result.verdict === "CONTRADICTED" && !assertionVerdicts.includes("CONTRADICTED")) errors.push("CONTRADICTED result has no contradicted assertion");
  if (result.verdict === "UNPROVEN" && assertionVerdicts.includes("CONTRADICTED")) errors.push("UNPROVEN result contains contradicted evidence");
  if (result.verifier.id === receipt.executor.id) errors.push("verifier and executor identities must be separate");
  if (result.proof.signerFingerprint === receipt.proof.signerFingerprint) errors.push("verifier and executor signing identities must be separate");
  if (receipt.executionState === "SUCCEEDED" && result.verdict === "VERIFIED" && result.assertionResults.some((assertion) => assertion.evidenceNodeIds.length === 0)) {
    errors.push("receipt success cannot produce VERIFIED without independent evidence nodes");
  }
  return errors;
}

if (basename(process.argv[1] || "") === "validate-contracts.mjs") {
  try {
    const count = await validateExamples();
    const evidenceSchemas = await validateEvidenceSchemas();
    console.log(`contract validation passed: ${count} structural examples, ${evidenceSchemas} evidence schemas`);
  } catch (error) {
    console.error(error.message);
    process.exit(1);
  }
}

SHA-256: d4bce723823fedeabe66d4cfdf078da137b077a0930fcd8494f87a39226e9109