← Files OpsTruthARCHIVED FILE

src/evidence-graph.js

33.1 KB · Oct 3, 2026 · 06:32 UTC

↓ Download file

import { canonicalDigest, canonicalJson, withoutFields } from "./canonical.js";
import { signProtocolArtifact, verifyProtocolArtifact } from "./protocol.js";

export const GRAPH_SCHEMA = "opstruth.evidence-graph";
export const GRAPH_VERSION = "1.0.0";
export const GRAPH_LIMITS = Object.freeze({ maxNodes: 256, maxEdges: 512, maxSnapshotBytes: 512 * 1024 });

const NODE_DOMAIN = "opstruth.evidence-node.v1\0";
const EDGE_DOMAIN = "opstruth.evidence-edge.v1\0";
const GRAPH_ID_DOMAIN = "opstruth.evidence-graph-id.v1\0";
const DELTA_DOMAIN = "opstruth.evidence-delta.v1\0";
const NODE_TYPES = new Set([
  "repository", "commit", "branch", "pull_request", "ci_run", "artifact", "deployment",
  "runtime_observation", "configuration", "finding", "action_request", "action_authorization",
  "execution_receipt", "verification_result",
]);
const EDGE_TYPES = new Set([
  "contains", "derived_from", "tested_by", "produced", "deployed_as", "observed_by",
  "addresses", "claims", "authorizes", "verifies", "contradicts", "supersedes",
]);
const VERDICTS = new Set(["VERIFIED", "PARTIAL", "CONTRADICTED", "UNPROVEN"]);
const RELEASE_SCOPE = Object.freeze([
  "branch_protection",
  "deployment_commit_binding",
  "migration_coverage",
  "migration_safety",
  "publication_state",
  "release_readiness",
  "rollback_viability",
  "runtime_correctness",
  "runtime_reachability",
]);

function isoTime(value) {
  const date = new Date(value || Date.now());
  if (Number.isNaN(date.getTime())) throw new Error("observation_time_invalid");
  return date.toISOString();
}

function freshUntil(observedAt, minutes = 5) {
  return new Date(Date.parse(observedAt) + minutes * 60 * 1000).toISOString();
}

function safeIdPart(value) {
  return encodeURIComponent(String(value ?? "unknown")).replaceAll("%", ".");
}

function nodeId(type, ...parts) {
  return `urn:opstruth:node:${type}:${parts.map(safeIdPart).join(":")}`;
}

function edgeId(type, from, to, suffix = "evidence") {
  return `urn:opstruth:edge:${type}:${safeIdPart(from)}:${safeIdPart(to)}:${safeIdPart(suffix)}`;
}

async function evidenceNode(value) {
  if (!NODE_TYPES.has(value.type)) throw new Error(`evidence_node_type_unknown:${value.type}`);
  const payload = withoutFields(value, ["digest"]);
  return { ...payload, digest: await canonicalDigest(NODE_DOMAIN, payload) };
}

async function evidenceEdge(value) {
  if (!EDGE_TYPES.has(value.type)) throw new Error(`evidence_edge_type_unknown:${value.type}`);
  const payload = withoutFields(value, ["digest"]);
  return { ...payload, digest: await canonicalDigest(EDGE_DOMAIN, payload) };
}

function source(provider, reference = null) {
  return { provider, ...(reference ? { reference } : {}) };
}

function authority(kind = "public") {
  return { kind, permissions: kind === "public" ? ["public_read"] : ["contents_read", "metadata_read", "actions_read", "pull_requests_read"] };
}

function assertion(assertionId, verdict, explanation, evidenceNodeIds = [], expected = true, observed = null) {
  return { assertionId, verdict, expected, observed, evidenceNodeIds: [...new Set(evidenceNodeIds)].sort(), explanation };
}

function overallVerdict(assertions, contradictions) {
  if (contradictions.length || assertions.some((item) => item.verdict === "CONTRADICTED")) return "CONTRADICTED";
  if (assertions.length && assertions.every((item) => item.verdict === "VERIFIED")) return "VERIFIED";
  if (assertions.some((item) => item.verdict === "VERIFIED")) return "PARTIAL";
  return "UNPROVEN";
}

function assessedScope(nodes) {
  const assessed = ["ci_commit_binding", "repository_head", "repository_identity"];
  if (matching(nodes, "runtime_observation").length) assessed.push("deployment_commit_binding", "runtime_reachability");
  if (matching(nodes, "action_request").length || matching(nodes, "action_authorization").length
    || matching(nodes, "execution_receipt").length || matching(nodes, "verification_result").length) {
    assessed.push("protocol_artifact_integrity");
  }
  return [...new Set(assessed)].sort();
}

function graphScope(nodes) {
  const assessed = assessedScope(nodes);
  return {
    assessed,
    notAssessed: RELEASE_SCOPE.filter((dimension) => !assessed.includes(dimension)),
  };
}

function matching(nodes, type) {
  return nodes.filter((node) => node.type === type);
}

function contradiction(rule, description, left, right) {
  return { rule, description, nodeIds: [left.id, right.id].sort() };
}

export function detectContradictions(nodes) {
  const contradictions = [];
  const commits = matching(nodes, "commit");
  const expectedCommit = commits.find((node) => node.attributes?.role === "observed_head");
  for (const ci of matching(nodes, "ci_run").filter((node) => node.attributes?.currentRelease === true)) {
    if (expectedCommit?.attributes?.sha && ci.attributes?.headCommitSha && ci.attributes.headCommitSha !== expectedCommit.attributes.sha) {
      contradictions.push(contradiction("ci_commit_mismatch", "The current CI run is bound to a different commit.", expectedCommit, ci));
    }
  }

  const artifacts = matching(nodes, "artifact");
  for (let leftIndex = 0; leftIndex < artifacts.length; leftIndex += 1) {
    for (let rightIndex = leftIndex + 1; rightIndex < artifacts.length; rightIndex += 1) {
      const left = artifacts[leftIndex];
      const right = artifacts[rightIndex];
      if (left.attributes?.releaseAssertion && left.attributes.releaseAssertion === right.attributes?.releaseAssertion
        && left.attributes?.contentDigest && right.attributes?.contentDigest
        && left.attributes.contentDigest !== right.attributes.contentDigest) {
        contradictions.push(contradiction("artifact_digest_mismatch", "Sources claim different artifact digests for the same release assertion.", left, right));
      }
    }
  }

  const deployments = matching(nodes, "deployment").filter((node) => node.attributes?.active === true);
  for (let leftIndex = 0; leftIndex < deployments.length; leftIndex += 1) {
    for (let rightIndex = leftIndex + 1; rightIndex < deployments.length; rightIndex += 1) {
      const left = deployments[leftIndex];
      const right = deployments[rightIndex];
      if (left.attributes?.environment && left.attributes.environment === right.attributes?.environment
        && left.attributes?.commitSha && right.attributes?.commitSha
        && left.attributes.commitSha !== right.attributes.commitSha) {
        contradictions.push(contradiction("active_deployment_mismatch", "Two active revisions conflict for a single-slot environment.", left, right));
      }
    }
  }

  const routes = matching(nodes, "configuration").filter((node) => node.attributes?.kind === "declared_route");
  const probes = matching(nodes, "runtime_observation");
  for (const route of routes) {
    const probe = probes.find((item) => item.attributes?.path === route.attributes?.path);
    if (probe && probe.attributes?.deploymentRef === route.attributes?.deploymentRef && probe.attributes?.ok === false) {
      contradictions.push(contradiction("declared_route_runtime_absent", "A declared route is absent from the bound fresh runtime observation.", route, probe));
    }
  }

  const requests = matching(nodes, "action_request");
  for (const receipt of matching(nodes, "execution_receipt")) {
    const request = requests.find((item) => item.attributes?.requestId === receipt.attributes?.requestId) || requests[0];
    if (request?.attributes?.digest && receipt.attributes?.requestDigest && request.attributes.digest !== receipt.attributes.requestDigest) {
      contradictions.push(contradiction("receipt_request_mismatch", "The execution receipt is bound to a different ActionRequest digest.", request, receipt));
    }
  }

  const current = nodes.filter((node) => node.attributes?.presentedAsCurrent === true);
  for (const later of nodes.filter((node) => node.attributes?.supersedesNodeId)) {
    const earlier = current.find((node) => node.id === later.attributes.supersedesNodeId);
    if (earlier) contradictions.push(contradiction("superseded_claim_presented_current", "A superseded claim is still presented as current.", earlier, later));
  }
  return contradictions.sort((left, right) => left.rule.localeCompare(right.rule) || left.nodeIds.join("").localeCompare(right.nodeIds.join("")));
}

async function contradictionEdges(contradictions, nodes, observedAt) {
  const byId = new Map(nodes.map((node) => [node.id, node]));
  const edges = [];
  for (const item of contradictions) {
    const [from, to] = item.nodeIds;
    if (!byId.has(from) || !byId.has(to)) continue;
    edges.push(await evidenceEdge({
      id: edgeId("contradicts", from, to, item.rule),
      from,
      to,
      type: "contradicts",
      source: source("opstruth-rule-engine", item.rule),
      observedAt,
      basis: "observed",
      evidenceNodeIds: item.nodeIds,
    }));
  }
  return edges;
}

function ensureLimits(nodes, edges) {
  if (nodes.length > GRAPH_LIMITS.maxNodes) throw new Error("evidence_graph_node_limit");
  if (edges.length > GRAPH_LIMITS.maxEdges) throw new Error("evidence_graph_edge_limit");
  if (new Set(nodes.map((node) => node.id)).size !== nodes.length) throw new Error("evidence_graph_duplicate_node_id");
  if (new Set(edges.map((edge) => edge.id)).size !== edges.length) throw new Error("evidence_graph_duplicate_edge_id");
}

export async function buildEvidenceGraph({ repositorySnapshot, deploymentReport = null, protocolArtifacts = [], observedAt, env = {} }) {
  if (!repositorySnapshot?.repository) throw new Error("repository_snapshot_required");
  const time = isoTime(observedAt);
  const repository = repositorySnapshot.repository;
  const repositoryIdentity = repository.providerRepositoryId || null;
  const commitSha = repository.headCommitSha || repositorySnapshot.githubStatus?.headCommitSha || null;
  const nodes = [];
  const edges = [];

  const repositoryNode = await evidenceNode({
    id: nodeId("repository", "github", repositoryIdentity || repository.fullName),
    type: "repository",
    subjectRef: { provider: "github", repositoryId: repositoryIdentity, repositoryName: repository.fullName },
    source: source(repository.metadataSource || "github-public-api", repository.htmlUrl),
    observedAt: time,
    authority: authority(repository.visibility === "private" ? "brokered_read" : "public"),
    freshUntil: freshUntil(time),
    status: repositoryIdentity ? "OBSERVED" : "UNAVAILABLE",
    attributes: { defaultBranch: repository.defaultBranch, visibility: repository.visibility, archived: repository.archived, fork: repository.fork },
  });
  nodes.push(repositoryNode);

  const branchNode = await evidenceNode({
    id: nodeId("branch", repositoryIdentity || repository.fullName, repository.defaultBranch),
    type: "branch",
    subjectRef: { provider: "github", repositoryId: repositoryIdentity, branch: repository.defaultBranch },
    source: source(repositorySnapshot.githubStatus?.source || repository.metadataSource || "github-public-api"),
    observedAt: time,
    authority: authority(repository.visibility === "private" ? "brokered_read" : "public"),
    freshUntil: freshUntil(time),
    status: commitSha ? "OBSERVED" : "UNAVAILABLE",
    attributes: { name: repository.defaultBranch, headCommitSha: commitSha },
  });
  nodes.push(branchNode);
  edges.push(await evidenceEdge({
    id: edgeId("contains", repositoryNode.id, branchNode.id), from: repositoryNode.id, to: branchNode.id, type: "contains",
    source: source(repositorySnapshot.githubStatus?.source || repository.metadataSource || "github-public-api"), observedAt: time, basis: "observed", evidenceNodeIds: [repositoryNode.id, branchNode.id],
  }));

  let commitNode = null;
  if (commitSha) {
    commitNode = await evidenceNode({
      id: nodeId("commit", repositoryIdentity || repository.fullName, commitSha),
      type: "commit",
      subjectRef: { provider: "github", repositoryId: repositoryIdentity, commitSha },
      source: source(repositorySnapshot.githubStatus?.source || repository.metadataSource || "github-public-api"),
      observedAt: time,
      authority: authority(repository.visibility === "private" ? "brokered_read" : "public"),
      freshUntil: freshUntil(time),
      status: "OBSERVED",
      attributes: { sha: commitSha, role: "observed_head", branch: repository.defaultBranch },
    });
    nodes.push(commitNode);
    edges.push(await evidenceEdge({
      id: edgeId("contains", branchNode.id, commitNode.id), from: branchNode.id, to: commitNode.id, type: "contains",
      source: source("github-public-api"), observedAt: time, basis: "observed", evidenceNodeIds: [branchNode.id, commitNode.id],
    }));
  }

  const runs = repositorySnapshot.githubStatus?.workflowRuns?.latest || [];
  for (const [index, run] of runs.slice(0, 20).entries()) {
    const ciNode = await evidenceNode({
      id: nodeId("ci_run", repositoryIdentity || repository.fullName, run.id),
      type: "ci_run",
      subjectRef: { provider: "github", repositoryId: repositoryIdentity, runId: String(run.id), headCommitSha: run.headSha || null },
      source: source("github-public-api", run.htmlUrl || null),
      observedAt: time,
      authority: authority(repository.visibility === "private" ? "brokered_read" : "public"),
      freshUntil: freshUntil(time),
      status: run.status === "completed" ? "OBSERVED" : "CLAIMED",
      attributes: {
        name: run.name, status: run.status, conclusion: run.conclusion, headCommitSha: run.headSha,
        currentRelease: index === 0, runNumber: run.runNumber, startedAt: run.startedAt, updatedAt: run.updatedAt,
      },
    });
    nodes.push(ciNode);
    if (commitNode && run.headSha === commitSha) {
      edges.push(await evidenceEdge({
        id: edgeId("tested_by", commitNode.id, ciNode.id), from: commitNode.id, to: ciNode.id, type: "tested_by",
        source: source("github-public-api"), observedAt: time, basis: "observed", evidenceNodeIds: [commitNode.id, ciNode.id],
      }));
    }
  }

  if (deploymentReport) {
    for (const probe of deploymentReport.probes || []) {
      const url = new URL(probe.requestedUrl);
      nodes.push(await evidenceNode({
        id: nodeId("runtime_observation", url.origin, url.pathname, time),
        type: "runtime_observation",
        subjectRef: { origin: url.origin, path: url.pathname, environment: deploymentReport.target?.environment || null },
        source: source("bounded-https-probe", probe.finalUrl || probe.requestedUrl),
        observedAt: time,
        authority: authority("public"),
        freshUntil: freshUntil(time),
        status: probe.ok ? "OBSERVED" : "UNAVAILABLE",
        attributes: { path: url.pathname, method: probe.method, status: probe.status, ok: probe.ok, deploymentRef: deploymentReport.target?.deploymentId || null },
      }));
    }
  }

  for (const artifact of protocolArtifacts.slice(0, 10)) {
    const type = ({
      "opstruth.action-request": "action_request",
      "opstruth.action-authorization": "action_authorization",
      "opstruth.execution-receipt": "execution_receipt",
      "opstruth.verification-result": "verification_result",
    })[artifact?.schema];
    if (!type) continue;
    const artifactVerification = await verifyProtocolArtifact(artifact, { now: time, trustedSignerFingerprints: [] });
    const cryptographicallyValid = artifactVerification.digestValid && (artifactVerification.signatureValid !== false);
    nodes.push(await evidenceNode({
      id: nodeId(type, artifact.requestId || artifact.authorizationId || artifact.receiptId || artifact.resultId || artifact.digest),
      type,
      subjectRef: artifact.subject || { requestDigest: artifact.requestDigest || artifact.digest },
      source: source("caller-supplied-protocol-artifact"),
      observedAt: time,
      authority: authority("caller_supplied"),
      freshUntil: artifact.expiresAt || null,
      status: cryptographicallyValid ? "CLAIMED" : "INVALID",
      attributes: {
        digest: artifact.digest, requestId: artifact.requestId || null, requestDigest: artifact.requestDigest || null,
        authorizationDigest: artifact.authorizationDigest || null, receiptDigest: artifact.receiptDigest || null,
        executionState: artifact.executionState || null,
        digestValid: artifactVerification.digestValid, signatureValid: artifactVerification.signatureValid, signerTrusted: false,
      },
    }));
  }

  const requestNode = matching(nodes, "action_request")[0];
  const authorizationNode = matching(nodes, "action_authorization")[0];
  const receiptNode = matching(nodes, "execution_receipt")[0];
  const resultNode = matching(nodes, "verification_result")[0];
  if (requestNode && authorizationNode && authorizationNode.attributes.requestDigest === requestNode.attributes.digest) {
    edges.push(await evidenceEdge({
      id: edgeId("authorizes", authorizationNode.id, requestNode.id), from: authorizationNode.id, to: requestNode.id, type: "authorizes",
      source: source("caller-supplied-protocol-artifact"), observedAt: time, basis: "observed", evidenceNodeIds: [authorizationNode.id, requestNode.id],
    }));
  }
  if (requestNode && receiptNode) {
    edges.push(await evidenceEdge({
      id: edgeId("claims", receiptNode.id, requestNode.id), from: receiptNode.id, to: requestNode.id, type: "claims",
      source: source("caller-supplied-protocol-artifact"), observedAt: time, basis: "observed", evidenceNodeIds: [receiptNode.id, requestNode.id],
    }));
  }
  if (receiptNode && resultNode && resultNode.attributes.receiptDigest === receiptNode.attributes.digest) {
    edges.push(await evidenceEdge({
      id: edgeId("verifies", resultNode.id, receiptNode.id), from: resultNode.id, to: receiptNode.id, type: "verifies",
      source: source("caller-supplied-protocol-artifact"), observedAt: time, basis: "observed", evidenceNodeIds: [resultNode.id, receiptNode.id],
    }));
  }

  const scope = graphScope(nodes);
  const scopeNode = await evidenceNode({
    id: nodeId("finding", repositoryIdentity || repository.fullName, "assessment-scope"),
    type: "finding",
    subjectRef: { provider: "github", repositoryId: repositoryIdentity, repositoryName: repository.fullName },
    source: source("opstruth-rule-engine", "assessment-scope-v1"),
    observedAt: time,
    authority: authority("public"),
    freshUntil: null,
    status: "OBSERVED",
    attributes: {
      kind: "assessment_scope",
      assessed: scope.assessed,
      notAssessed: scope.notAssessed,
      statement: "Repository identity and available exact-head CI evidence are scoped assertions; release readiness remains unproven.",
    },
  });
  nodes.push(scopeNode);
  edges.push(await evidenceEdge({
    id: edgeId("addresses", scopeNode.id, repositoryNode.id, "assessment-scope"),
    from: scopeNode.id,
    to: repositoryNode.id,
    type: "addresses",
    source: source("opstruth-rule-engine", "assessment-scope-v1"),
    observedAt: time,
    basis: "observed",
    evidenceNodeIds: [scopeNode.id, repositoryNode.id],
  }));

  const contradictions = detectContradictions(nodes);
  edges.push(...await contradictionEdges(contradictions, nodes, time));
  const assertions = [];
  assertions.push(repositoryIdentity
    ? assertion("repository.identity", "VERIFIED", "GitHub returned an immutable provider repository identifier.", [repositoryNode.id], repositoryIdentity, repositoryIdentity)
    : assertion("repository.identity", "UNPROVEN", "The repository name was observed but no immutable provider identifier was available.", [repositoryNode.id], "immutable provider repository ID", null));
  assertions.push(commitNode
    ? assertion("repository.head_commit", "VERIFIED", "The default branch head commit was observed.", [commitNode.id], "current head commit", commitSha)
    : assertion("repository.head_commit", "UNPROVEN", "The default branch head commit could not be observed.", [], "current head commit", null));
  const currentCi = matching(nodes, "ci_run").find((node) => node.attributes?.currentRelease === true);
  if (currentCi) {
    if (currentCi.attributes.headCommitSha !== commitSha) assertions.push(assertion("ci.commit_binding", "CONTRADICTED", "The latest CI run is not bound to the observed head commit.", [commitNode?.id, currentCi.id].filter(Boolean), commitSha, currentCi.attributes.headCommitSha));
    else if (currentCi.attributes.status !== "completed") assertions.push(assertion("ci.commit_binding", "UNPROVEN", "The current commit CI run has not completed.", [currentCi.id], "completed", currentCi.attributes.status));
    else if (currentCi.attributes.conclusion !== "success") assertions.push(assertion("ci.commit_binding", "CONTRADICTED", "The current commit CI run did not succeed.", [currentCi.id], "success", currentCi.attributes.conclusion));
    else assertions.push(assertion("ci.commit_binding", "VERIFIED", "The latest completed successful CI run is bound to the observed head commit.", [commitNode?.id, currentCi.id].filter(Boolean), commitSha, currentCi.attributes.headCommitSha));
  } else {
    assertions.push(assertion("ci.commit_binding", "UNPROVEN", "No current CI run was available.", [], "successful current-commit CI", null));
  }
  if (deploymentReport) {
    assertions.push(assertion(
      "deployment.commit_binding",
      "UNPROVEN",
      "The public runtime was observed, but no provider deployment identity bound it to the repository commit.",
      matching(nodes, "runtime_observation").map((node) => node.id),
      commitSha,
      null,
    ));
  }
  assertions.push(assertion(
    "release.readiness",
    "UNPROVEN",
    "Repository and observed CI evidence do not establish deployment, publication, migration, rollback, or release readiness.",
    [scopeNode.id, repositoryNode.id, commitNode?.id, currentCi?.id].filter(Boolean),
    "independently verified release readiness",
    null,
  ));
  const verdict = overallVerdict(assertions, contradictions);
  ensureLimits(nodes, edges);
  nodes.sort((left, right) => left.id.localeCompare(right.id));
  edges.sort((left, right) => left.id.localeCompare(right.id));
  const body = {
    schema: GRAPH_SCHEMA,
    schemaVersion: GRAPH_VERSION,
    createdAt: time,
    subject: { provider: "github", repositoryId: repositoryIdentity, repositoryName: repository.fullName, commitSha },
    policy: { stateless: true, independentlyVerified: true, graphLimits: GRAPH_LIMITS },
    nodes,
    edges,
    summary: {
      verdict,
      assertionResults: assertions,
      contradictions,
      counts: { nodes: nodes.length, edges: edges.length, contradictions: contradictions.length, unproven: assertions.filter((item) => item.verdict === "UNPROVEN").length },
    },
  };
  const graphIdDigest = await canonicalDigest(GRAPH_ID_DOMAIN, body);
  const signed = await signProtocolArtifact({ ...body, graphId: `urn:opstruth:evidence-graph:${graphIdDigest.slice("sha256:".length)}` }, env);
  const serializedBytes = new TextEncoder().encode(JSON.stringify(signed)).byteLength;
  if (serializedBytes > GRAPH_LIMITS.maxSnapshotBytes) throw new Error("evidence_graph_snapshot_limit");
  return signed;
}

export async function verifyEvidenceGraph(graph, options = {}) {
  const protocol = await verifyProtocolArtifact(graph, options);
  const errors = [...protocol.errors];
  const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
  const edges = Array.isArray(graph?.edges) ? graph.edges : [];
  const checkedAt = Date.parse(options.now || new Date().toISOString());
  const staleNodeIds = nodes.filter((node) => node.freshUntil && Date.parse(node.freshUntil) <= checkedAt).map((node) => node.id).sort();
  try { ensureLimits(nodes, edges); } catch (error) { errors.push(error.message); }
  try {
    const serializedBytes = new TextEncoder().encode(JSON.stringify(graph)).byteLength;
    if (serializedBytes > GRAPH_LIMITS.maxSnapshotBytes) errors.push("evidence_graph_snapshot_limit");
  } catch {
    errors.push("evidence_graph_serialization_invalid");
  }
  for (const node of nodes) {
    if (!NODE_TYPES.has(node.type)) errors.push(`unknown_node_type:${node.type}`);
    const expected = await canonicalDigest(NODE_DOMAIN, withoutFields(node, ["digest"])).catch(() => null);
    if (!expected || expected !== node.digest) errors.push(`node_digest_mismatch:${node.id || "unknown"}`);
  }
  const nodeIds = new Set(nodes.map((node) => node.id));
  for (const edge of edges) {
    if (!EDGE_TYPES.has(edge.type)) errors.push(`unknown_edge_type:${edge.type}`);
    if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) errors.push(`edge_endpoint_missing:${edge.id || "unknown"}`);
    for (const evidenceNodeId of edge.evidenceNodeIds || []) {
      if (!nodeIds.has(evidenceNodeId)) errors.push(`edge_evidence_node_missing:${edge.id || "unknown"}:${evidenceNodeId}`);
    }
    const expected = await canonicalDigest(EDGE_DOMAIN, withoutFields(edge, ["digest"])).catch(() => null);
    if (!expected || expected !== edge.digest) errors.push(`edge_digest_mismatch:${edge.id || "unknown"}`);
  }
  const assertions = Array.isArray(graph?.summary?.assertionResults) ? graph.summary.assertionResults : [];
  for (const item of assertions) {
    for (const evidenceNodeId of item.evidenceNodeIds || []) {
      if (!nodeIds.has(evidenceNodeId)) errors.push(`assertion_evidence_node_missing:${item.assertionId || "unknown"}:${evidenceNodeId}`);
    }
  }
  const repositoryNodes = matching(nodes, "repository");
  if (repositoryNodes.length !== 1) errors.push("repository_node_cardinality_invalid");
  const repositoryNode = repositoryNodes[0];
  if (repositoryNode && (
    repositoryNode.subjectRef?.provider !== graph?.subject?.provider
    || repositoryNode.subjectRef?.repositoryId !== graph?.subject?.repositoryId
    || repositoryNode.subjectRef?.repositoryName !== graph?.subject?.repositoryName
  )) errors.push("graph_subject_repository_mismatch");
  const observedHeads = matching(nodes, "commit").filter((node) => node.attributes?.role === "observed_head");
  if (graph?.subject?.commitSha && (observedHeads.length !== 1 || observedHeads[0].attributes?.sha !== graph.subject.commitSha)) {
    errors.push("graph_subject_commit_mismatch");
  }
  let computedContradictions = [];
  try {
    computedContradictions = detectContradictions(nodes);
    if (canonicalJson(computedContradictions) !== canonicalJson(graph?.summary?.contradictions || [])) errors.push("graph_contradiction_summary_mismatch");
  } catch {
    errors.push("graph_contradiction_summary_invalid");
  }
  const counts = graph?.summary?.counts || {};
  if (counts.nodes !== nodes.length) errors.push("graph_node_count_mismatch");
  if (counts.edges !== edges.length) errors.push("graph_edge_count_mismatch");
  if (counts.contradictions !== computedContradictions.length) errors.push("graph_contradiction_count_mismatch");
  if (counts.unproven !== assertions.filter((item) => item.verdict === "UNPROVEN").length) errors.push("graph_unproven_count_mismatch");
  const scopeNodes = matching(nodes, "finding").filter((node) => node.attributes?.kind === "assessment_scope");
  const hasScopedAssessment = scopeNodes.length > 0;
  const expectedScope = graphScope(nodes);
  const releaseAssertions = assertions.filter((item) => item.assertionId === "release.readiness");
  if (hasScopedAssessment) {
    if (scopeNodes.length !== 1
      || typeof scopeNodes[0].attributes?.statement !== "string"
      || canonicalJson(scopeNodes[0].attributes?.assessed || []) !== canonicalJson(expectedScope.assessed)
      || canonicalJson(scopeNodes[0].attributes?.notAssessed || []) !== canonicalJson(expectedScope.notAssessed)) {
      errors.push("graph_scope_summary_mismatch");
    }
    if (releaseAssertions.length !== 1 || releaseAssertions[0].verdict !== "UNPROVEN") errors.push("graph_release_readiness_scope_invalid");
  } else if (releaseAssertions.length) {
    errors.push("graph_scope_summary_missing");
  }
  const expectedVerdict = overallVerdict(assertions, computedContradictions);
  if (!VERDICTS.has(graph?.summary?.verdict) || graph?.summary?.verdict !== expectedVerdict) errors.push("graph_verdict_invalid");
  try {
    const graphIdPayload = withoutFields(graph, ["graphId", "digest", "proof"]);
    const expectedGraphIdDigest = await canonicalDigest(GRAPH_ID_DOMAIN, graphIdPayload);
    const expectedGraphId = `urn:opstruth:evidence-graph:${expectedGraphIdDigest.slice("sha256:".length)}`;
    if (graph?.graphId !== expectedGraphId) errors.push("graph_id_mismatch");
  } catch {
    errors.push("graph_id_invalid");
  }
  return {
    valid: errors.length === 0,
    digestValid: protocol.digestValid,
    signatureValid: protocol.signatureValid,
    trusted: protocol.trusted,
    integrity: protocol.signatureValid ? "signed" : protocol.digestValid ? "digest_only" : "invalid",
    signerFingerprint: protocol.signerFingerprint,
    scopeIntegrity: hasScopedAssessment ? "scoped" : "legacy_unscoped",
    releaseReadiness: "UNPROVEN",
    staleNodeIds,
    errors: [...new Set(errors)],
  };
}

function semanticNodeKey(node) {
  if (node.type === "runtime_observation") {
    return canonicalJson({ type: node.type, subjectRef: node.subjectRef });
  }
  return node.id;
}

function semanticNodeValue(node) {
  return withoutFields(node, ["id", "digest", "observedAt", "freshUntil"]);
}

function semanticEdgeKey(edge, nodeKeys) {
  return canonicalJson({
    type: edge.type,
    from: nodeKeys.get(edge.from) || edge.from,
    to: nodeKeys.get(edge.to) || edge.to,
    suffix: String(edge.id || "").split(":").at(-1) || "evidence",
  });
}

function semanticEdgeValue(edge, nodeKeys) {
  return {
    ...withoutFields(edge, ["id", "digest", "observedAt", "from", "to", "evidenceNodeIds"]),
    from: nodeKeys.get(edge.from) || edge.from,
    to: nodeKeys.get(edge.to) || edge.to,
    evidenceNodeIds: (edge.evidenceNodeIds || []).map((id) => nodeKeys.get(id) || id).sort(),
  };
}

function semanticContradictionKey(item, nodeKeys) {
  return canonicalJson({
    rule: item.rule,
    nodeKeys: (item.nodeIds || []).map((id) => nodeKeys.get(id) || id).sort(),
  });
}

function mapSemantic(values, keyFor, valueFor) {
  const result = new Map();
  for (const value of values || []) {
    const key = keyFor(value);
    if (result.has(key)) throw new Error("evidence_graph_semantic_identity_collision");
    result.set(key, { id: value.id, value: valueFor(value), freshUntil: value.freshUntil });
  }
  return result;
}

function changedIds(before, after, comparedAt) {
  const added = [];
  const removed = [];
  const changed = [];
  for (const [key, item] of after) {
    if (!before.has(key)) added.push(item.id);
    else if (canonicalJson(before.get(key).value) !== canonicalJson(item.value)) changed.push(item.id);
  }
  for (const [key, item] of before) if (!after.has(key)) removed.push(item.id);
  const timestamp = Date.parse(comparedAt);
  const stale = [...after.values()].filter((item) => item.freshUntil && Date.parse(item.freshUntil) <= timestamp).map((item) => item.id).sort();
  return { added: added.sort(), removed: removed.sort(), changed: changed.sort(), stale };
}

export async function compareEvidenceGraphs(before, after, options = {}) {
  const comparedAt = isoTime(options.comparedAt);
  const [beforeVerification, afterVerification] = await Promise.all([
    verifyEvidenceGraph(before, { ...options, now: comparedAt }),
    verifyEvidenceGraph(after, { ...options, now: comparedAt }),
  ]);
  if (before?.schemaVersion !== GRAPH_VERSION || after?.schemaVersion !== GRAPH_VERSION) throw new Error("evidence_graph_version_incompatible");
  if (!beforeVerification.valid) throw new Error(`evidence_graph_before_invalid:${beforeVerification.errors.join("|")}`);
  if (!afterVerification.valid) throw new Error(`evidence_graph_after_invalid:${afterVerification.errors.join("|")}`);
  if ((options.trustedSignerFingerprints || []).length && (!beforeVerification.trusted || !afterVerification.trusted)) {
    throw new Error("evidence_graph_signer_untrusted");
  }
  const beforeSubject = before?.subject || {};
  const afterSubject = after?.subject || {};
  if (!beforeSubject.repositoryId || beforeSubject.repositoryId !== afterSubject.repositoryId || beforeSubject.provider !== afterSubject.provider) {
    throw new Error("evidence_graph_subject_incompatible");
  }
  const beforeNodeKeys = new Map((before.nodes || []).map((node) => [node.id, semanticNodeKey(node)]));
  const afterNodeKeys = new Map((after.nodes || []).map((node) => [node.id, semanticNodeKey(node)]));
  const nodeChanges = changedIds(
    mapSemantic(before.nodes, semanticNodeKey, semanticNodeValue),
    mapSemantic(after.nodes, semanticNodeKey, semanticNodeValue),
    comparedAt,
  );
  const edgeChanges = changedIds(
    mapSemantic(before.edges, (edge) => semanticEdgeKey(edge, beforeNodeKeys), (edge) => semanticEdgeValue(edge, beforeNodeKeys)),
    mapSemantic(after.edges, (edge) => semanticEdgeKey(edge, afterNodeKeys), (edge) => semanticEdgeValue(edge, afterNodeKeys)),
    comparedAt,
  );
  const beforeContradictions = new Set((before.summary?.contradictions || []).map((item) => semanticContradictionKey(item, beforeNodeKeys)));
  const payload = {
    schema: "opstruth.evidence-delta",
    schemaVersion: "1.0.0",
    comparedAt,
    subject: after.subject,
    before: { graphId: before.graphId, digest: before.digest, verification: beforeVerification },
    after: { graphId: after.graphId, digest: after.digest, verification: afterVerification },
    nodeChanges,
    edgeChanges,
    verdictTransition: { from: before.summary?.verdict || "UNPROVEN", to: after.summary?.verdict || "UNPROVEN" },
    newlyContradicted: (after.summary?.contradictions || []).filter((item) => !beforeContradictions.has(semanticContradictionKey(item, afterNodeKeys))),
  };
  return { ...payload, digest: await canonicalDigest(DELTA_DOMAIN, payload) };
}

SHA-256: 7109f9f5bccb7203efedb5896a4e0ea47339bdb9187d23ffdd3e9fbf48d45654