import { loadEd } from '../ed25519'; import { canonicalJson, computeSignerFingerprint, GENESIS_PREVIOUS_HASH, sha256Hex, verifyEntry } from '../decision-log'; import type { SignedDecisionLogEntry, SpendDecision } from '../types'; export type ReceiptTopology = 'linear' | 'fan_in' | 'fan_out' | 'diamond'; export type ReceiptCapabilityLevel = 'READ_ONLY' | 'TRANSACT' | 'ADMIN' | 'ORCHESTRATE'; export interface ReceiptDagNode { version: number; sequence: number; decision: Record; workflowId: string | null; parents: string[]; topology: ReceiptTopology; msgHash: string; trustScore: number; blocked: boolean; capability: ReceiptCapabilityLevel; entryHash: string; signature: string; signerFingerprint: string; builderCode?: string | null; timestamp: string; previousHash?: string; } export interface ReceiptDagEnvelope { nodes: ReceiptDagInput[]; rootId: string; workflowId: string | null; } export type ReceiptDagInput = ReceiptDagNode | SignedDecisionLogEntry; export interface ReceiptDagVerificationResult { valid: boolean; compositeTrust: number; depth: number; topologySummary: Record; reason?: string; } export interface CapabilityGateThresholds { READ_ONLY?: { minTrust?: number; minDepth?: number }; TRANSACT?: { minTrust?: number; minDepth?: number }; ADMIN?: { minTrust?: number; minDepth?: number }; ORCHESTRATE?: { minTrust?: number; minDepth?: number }; } export interface CapabilityGateResult { granted: boolean; requestedLevel: ReceiptCapabilityLevel; compositeTrust: number; depth: number; reason?: string; } export interface MerkleProofStep { siblingHash: string; position: 'left' | 'right'; } export interface MerkleCompressionResult { compressed: boolean; root: string; nodeCount: number; } const DEFAULT_THRESHOLDS: Required = { READ_ONLY: { minTrust: 0.5, minDepth: 1 }, TRANSACT: { minTrust: 0.72, minDepth: 1 }, ADMIN: { minTrust: 0.82, minDepth: 2 }, ORCHESTRATE: { minTrust: 0.9, minDepth: 3 }, }; const DATA_PLANE_KEYS = /^(prompt|completion|messages|content|source|pii|raw|rawPrompt|rawCompletion|apiKey|secret)$/i; interface NormalizedDagNode extends ReceiptDagNode { legacyPreviousHash: string | null; legacy: boolean; } export async function signDagNode(args: { sequence: number; decision: Record | SpendDecision; privateKey: Uint8Array; publicKey: Uint8Array; parents?: Array; workflowId?: string | null; topology?: ReceiptTopology; trustScore?: number; blocked?: boolean; capability?: ReceiptCapabilityLevel; builderCode?: string | null; timestamp?: string; version?: number; }): Promise { const decision = sanitizeMetadata(args.decision as Record); const parents = (args.parents ?? []).map(parentHash); const unsigned = { version: args.version ?? 2, sequence: args.sequence, decision, workflowId: args.workflowId ?? stringOrNull(decision.workflowId) ?? stringOrNull(decision.workflow_id), parents, topology: args.topology ?? topologyForParentCount(parents.length), msgHash: msgHashForDecision(decision), trustScore: clampTrust(args.trustScore ?? deriveTrustScore(decision)), blocked: args.blocked ?? decision.action === 'block', capability: args.capability ?? deriveCapability(decision), signerFingerprint: computeSignerFingerprint(args.publicKey), builderCode: args.builderCode ?? null, timestamp: args.timestamp ?? stringOrNull(decision.timestamp) ?? new Date().toISOString(), }; const entryHash = computeDagEntryHash(unsigned); const ed = await loadEd(); const signature = Buffer.from(await ed.signAsync(Buffer.from(entryHash, 'hex'), args.privateKey)).toString('hex'); return { ...unsigned, entryHash, signature }; } export function computeDagEntryHash(node: Omit | Record): string { const { entryHash: _entryHash, signature: _signature, previousHash: _previousHash, legacyPreviousHash: _legacyPreviousHash, legacy: _legacy, ...payload } = node as Record; return sha256Hex(canonicalJson(payload)); } export function msgHashForDecision(decision: Record): string { assertMetadataOnly(decision); return sha256Hex(canonicalJson(decision)).slice(0, 16); } export function deriveTrustScore(decision: Record): number { const action = typeof decision.action === 'string' ? decision.action : ''; const entryType = typeof decision.entryType === 'string' ? decision.entryType : ''; const reasons = Array.isArray(decision.reasons) ? decision.reasons.map(String).join(' ').toLowerCase() : ''; if (action === 'block' || reasons.includes('blocked') || reasons.includes('cap reached')) return 0.2; if (entryType === 'settlement' && decision.partial === true) return 0.55; if (entryType === 'governance') return 0.78; if (entryType === 'outcome') return decision.partial === true ? 0.62 : 0.88; if (action === 'shadow') return 0.74; if (action === 'downgrade') return 0.82; if (action === 'allow') return 0.94; return 0.7; } export function deriveCapability(decision: Record): ReceiptCapabilityLevel { const raw = String( decision.capability ?? decision.capabilityClaim ?? decision.requiredCapability ?? (((decision.outcomeReceipt as Record | undefined)?.metadata as Record | undefined)?.capability) ?? '', ).toLowerCase(); if (raw.includes('orchestrate')) return 'ORCHESTRATE'; if (raw.includes('payment')) return 'TRANSACT'; if (raw.includes('transaction') || raw.includes('transact')) return 'TRANSACT'; if (raw.includes('admin') || raw.includes('data_write') || raw.includes('write')) return 'ADMIN'; return 'READ_ONLY'; } export async function verifyReceiptDag( nodes: ReceiptDagInput[] | ReceiptDagEnvelope, signerKeys: Record | string | Uint8Array, ): Promise { const inputs = Array.isArray(nodes) ? nodes : nodes.nodes; if (inputs.length === 0) return { valid: true, compositeTrust: 1, depth: 0, topologySummary: emptyTopologySummary() }; let normalized: NormalizedDagNode[]; try { normalized = inputs.map(normalizeDagNode); } catch (err) { return failure(String((err as Error).message || err)); } const byHash = new Map(); for (const node of normalized) { if (byHash.has(node.entryHash)) return failure('DUPLICATE_NODE_HASH'); byHash.set(node.entryHash, node); } const kahn = topologicalOrder(normalized, byHash); if (!kahn.valid) return failure(kahn.reason ?? 'CYCLE_DETECTED'); for (const node of normalized) { const key = keyForNode(node, signerKeys); if (!key) return failure('SIGNER_KEY_NOT_FOUND'); const publicKey = normalizePublicKey(key); if (node.legacy) { const legacyOk = await verifyEntry( { sequence: node.sequence, decision: node.decision as unknown as SpendDecision, previousHash: node.legacyPreviousHash ?? GENESIS_PREVIOUS_HASH, entryHash: node.entryHash, signature: node.signature, signerFingerprint: node.signerFingerprint, builderCode: node.builderCode ?? undefined, }, publicKey, ); if (!legacyOk) return failure('LEGACY_SIGNATURE_OR_HASH_INVALID'); continue; } assertMetadataOnly(node.decision); const expectedHash = computeDagEntryHash(node); if (expectedHash !== node.entryHash) return failure('HASH_INVALID'); if (computeSignerFingerprint(publicKey) !== node.signerFingerprint) return failure('SIGNER_FINGERPRINT_MISMATCH'); const ed = await loadEd(); const ok = await ed.verifyAsync(Buffer.from(node.signature, 'hex'), Buffer.from(node.entryHash, 'hex'), publicKey); if (!ok) return failure('SIGNATURE_INVALID'); } const diamond = verifyDiamondCorrelation(normalized, byHash); if (!diamond.valid) return failure(diamond.reason ?? 'DIAMOND_ANCESTOR_MISMATCH'); const depths = computeDepths(kahn.order, byHash); const maxDepth = Math.max(...Array.from(depths.values()), 1); const topologySummary = summarizeTopology(normalized, byHash); return { valid: true, compositeTrust: Math.min(...normalized.map((node) => node.trustScore)), depth: maxDepth, topologySummary, }; } export function gateCapability( requestedLevel: ReceiptCapabilityLevel, dagVerificationResult: ReceiptDagVerificationResult, thresholds: CapabilityGateThresholds = {}, ): CapabilityGateResult { const merged = { ...DEFAULT_THRESHOLDS[requestedLevel], ...(thresholds[requestedLevel] ?? {}) }; const threshold = { minTrust: merged.minTrust ?? 0, minDepth: merged.minDepth ?? 0 }; if (!dagVerificationResult.valid) { return { granted: false, requestedLevel, compositeTrust: dagVerificationResult.compositeTrust, depth: dagVerificationResult.depth, reason: dagVerificationResult.reason ?? 'DAG_VERIFICATION_FAILED', }; } if (dagVerificationResult.compositeTrust < threshold.minTrust) { return { granted: false, requestedLevel, compositeTrust: dagVerificationResult.compositeTrust, depth: dagVerificationResult.depth, reason: 'TRUST_BELOW_THRESHOLD', }; } if (dagVerificationResult.depth < threshold.minDepth) { return { granted: false, requestedLevel, compositeTrust: dagVerificationResult.compositeTrust, depth: dagVerificationResult.depth, reason: 'DEPTH_BELOW_THRESHOLD', }; } return { granted: true, requestedLevel, compositeTrust: dagVerificationResult.compositeTrust, depth: dagVerificationResult.depth, }; } export function merkleRoot(nodes: ReceiptDagInput[]): string { const leaves = nodes.map((node) => normalizeDagNode(node).entryHash).sort(); return merkleRootFromLeaves(leaves); } export function compressReceiptDag(nodes: ReceiptDagInput[], threshold = 10): MerkleCompressionResult { return { compressed: nodes.length > threshold, root: merkleRoot(nodes), nodeCount: nodes.length, }; } export function merkleInclusionProof(nodes: ReceiptDagInput[], node: ReceiptDagInput | string): MerkleProofStep[] { let leaves = nodes.map((item) => normalizeDagNode(item).entryHash).sort(); const target = typeof node === 'string' ? node : normalizeDagNode(node).entryHash; let index = leaves.indexOf(target); if (index < 0) throw new Error('Merkle target node not found'); const proof: MerkleProofStep[] = []; while (leaves.length > 1) { if (leaves.length % 2 === 1) leaves = [...leaves, leaves[leaves.length - 1]!]; const siblingIndex = index % 2 === 0 ? index + 1 : index - 1; proof.push({ siblingHash: leaves[siblingIndex]!, position: index % 2 === 0 ? 'right' : 'left' }); const next: string[] = []; for (let i = 0; i < leaves.length; i += 2) next.push(hashPair(leaves[i]!, leaves[i + 1]!)); index = Math.floor(index / 2); leaves = next; } return proof; } export function verifyInclusion(node: ReceiptDagInput | string, proof: MerkleProofStep[], root: string): boolean { let hash = typeof node === 'string' ? node : normalizeDagNode(node).entryHash; for (const step of proof) { hash = step.position === 'left' ? hashPair(step.siblingHash, hash) : hashPair(hash, step.siblingHash); } return hash === root; } export function dagEnvelopeFromEntries(entries: SignedDecisionLogEntry[], workflowId: string | null = null): ReceiptDagEnvelope { const nodes = entries.map((entry) => normalizeDagNode(entry)); const rootId = nodes[nodes.length - 1]?.entryHash ?? GENESIS_PREVIOUS_HASH; return { nodes, rootId, workflowId }; } function normalizeDagNode(input: ReceiptDagInput): NormalizedDagNode { const candidate = input as Partial & SignedDecisionLogEntry; if ((candidate as { legacy?: boolean }).legacy !== true && Array.isArray(candidate.parents) && candidate.msgHash && candidate.topology) { return { version: numberOr(candidate.version, 2), sequence: candidate.sequence, decision: sanitizeMetadata(candidate.decision as Record), workflowId: candidate.workflowId ?? null, parents: [...candidate.parents], topology: candidate.topology, msgHash: candidate.msgHash, trustScore: clampTrust(candidate.trustScore), blocked: Boolean(candidate.blocked), capability: candidate.capability ?? deriveCapability(candidate.decision as Record), entryHash: candidate.entryHash, signature: candidate.signature, signerFingerprint: candidate.signerFingerprint, builderCode: candidate.builderCode ?? null, timestamp: candidate.timestamp ?? new Date(0).toISOString(), previousHash: candidate.previousHash, legacyPreviousHash: null, legacy: false, }; } const previousHash = candidate.previousHash ?? GENESIS_PREVIOUS_HASH; const parents = previousHash === GENESIS_PREVIOUS_HASH ? [] : [previousHash]; const decision = sanitizeMetadata(candidate.decision as unknown as Record); return { version: 1, sequence: candidate.sequence, decision, workflowId: stringOrNull(decision.workflowId) ?? stringOrNull(decision.workflow_id), parents, topology: 'linear', msgHash: msgHashForDecision(decision), trustScore: deriveTrustScore(decision), blocked: decision.action === 'block', capability: deriveCapability(decision), entryHash: candidate.entryHash, signature: candidate.signature, signerFingerprint: candidate.signerFingerprint, builderCode: candidate.builderCode ?? null, timestamp: stringOrNull(decision.timestamp) ?? new Date(0).toISOString(), previousHash, legacyPreviousHash: previousHash, legacy: true, }; } function topologicalOrder(nodes: NormalizedDagNode[], byHash: Map): { valid: boolean; order: string[]; reason?: string } { const indegree = new Map(); const children = new Map(); for (const node of nodes) { const parentRefs = node.parents.filter((parent) => parent !== GENESIS_PREVIOUS_HASH); for (const parent of parentRefs) { if (!byHash.has(parent)) return { valid: false, order: [], reason: 'MISSING_PARENT' }; const list = children.get(parent) ?? []; list.push(node.entryHash); children.set(parent, list); } indegree.set(node.entryHash, parentRefs.length); } const queue = Array.from(indegree.entries()).filter(([, degree]) => degree === 0).map(([hash]) => hash); const order: string[] = []; while (queue.length) { const current = queue.shift()!; order.push(current); for (const child of children.get(current) ?? []) { const next = (indegree.get(child) ?? 0) - 1; indegree.set(child, next); if (next === 0) queue.push(child); } } if (order.length !== nodes.length) return { valid: false, order, reason: 'CYCLE_DETECTED' }; return { valid: true, order }; } function verifyDiamondCorrelation(nodes: NormalizedDagNode[], byHash: Map): { valid: boolean; reason?: string } { for (const node of nodes) { if (node.parents.length < 2) continue; const parentNodes = node.parents.map((parent) => byHash.get(parent)).filter(Boolean) as NormalizedDagNode[]; if (parentNodes.length < 2) continue; const ancestorSets = parentNodes.map((parent) => ancestorsOf(parent.entryHash, byHash)); const common = [...ancestorSets[0]!].filter((hash) => ancestorSets.every((set) => set.has(hash))); const isDiamond = node.topology === 'diamond' || common.length > 0; if (!isDiamond) continue; if (!common.length) return { valid: false, reason: 'DIAMOND_ANCESTOR_MISMATCH' }; const correlated = common.some((ancestorHash) => { const ancestor = byHash.get(ancestorHash); return ancestor ? parentNodes.every((parent) => correlatesToAncestor(parent, ancestor)) : false; }); if (!correlated) return { valid: false, reason: 'DIAMOND_MSGHASH_MISMATCH' }; } return { valid: true }; } function ancestorsOf(hash: string, byHash: Map, seen = new Set()): Set { const node = byHash.get(hash); if (!node) return seen; for (const parent of node.parents) { if (seen.has(parent)) continue; seen.add(parent); ancestorsOf(parent, byHash, seen); } return seen; } function correlatesToAncestor(node: NormalizedDagNode, ancestor: NormalizedDagNode): boolean { if (node.msgHash === ancestor.msgHash) return true; const flattened = flattenValues(node.decision); return flattened.includes(ancestor.msgHash) || flattened.includes(ancestor.entryHash); } function flattenValues(value: unknown): string[] { if (value == null) return []; if (typeof value === 'string') return [value]; if (typeof value === 'number' || typeof value === 'boolean') return [String(value)]; if (Array.isArray(value)) return value.flatMap(flattenValues); if (typeof value === 'object') return Object.values(value as Record).flatMap(flattenValues); return []; } function computeDepths(order: string[], byHash: Map): Map { const depths = new Map(); for (const hash of order) { const node = byHash.get(hash)!; const parentDepth = node.parents.length ? Math.max(0, ...node.parents.map((parent) => depths.get(parent) ?? 0)) : 0; depths.set(hash, parentDepth + 1); } return depths; } function summarizeTopology(nodes: NormalizedDagNode[], byHash: Map): Record { const childCounts = new Map(); for (const node of nodes) { for (const parent of node.parents) childCounts.set(parent, (childCounts.get(parent) ?? 0) + 1); } const summary = emptyTopologySummary(); for (const node of nodes) { let topology: ReceiptTopology = node.topology; if (node.parents.length > 1) { const parentNodes = node.parents.map((parent) => byHash.get(parent)).filter(Boolean) as NormalizedDagNode[]; const common = parentNodes.length > 1 ? [...ancestorsOf(parentNodes[0]!.entryHash, byHash)].filter((hash) => parentNodes.every((parent) => ancestorsOf(parent.entryHash, byHash).has(hash))) : []; topology = common.length ? 'diamond' : 'fan_in'; } else if (childCounts.get(node.entryHash)! > 1) { topology = 'fan_out'; } summary[topology] += 1; } return summary; } function emptyTopologySummary(): Record { return { linear: 0, fan_in: 0, fan_out: 0, diamond: 0 }; } function failure(reason: string): ReceiptDagVerificationResult { return { valid: false, compositeTrust: 0, depth: 0, topologySummary: emptyTopologySummary(), reason }; } function keyForNode(node: ReceiptDagNode, keys: Record | string | Uint8Array): string | Uint8Array | null { if (typeof keys === 'string' || keys instanceof Uint8Array) return keys; return keys[node.signerFingerprint] ?? null; } function normalizePublicKey(key: string | Uint8Array): Uint8Array { if (key instanceof Uint8Array) return key; const clean = key.replace(/^0x/, ''); return Uint8Array.from(Buffer.from(clean, 'hex')); } function parentHash(parent: ReceiptDagNode | SignedDecisionLogEntry | string): string { return typeof parent === 'string' ? parent : parent.entryHash; } function topologyForParentCount(count: number): ReceiptTopology { if (count === 0 || count === 1) return 'linear'; return 'fan_in'; } function merkleRootFromLeaves(input: string[]): string { if (input.length === 0) return sha256Hex(''); let leaves = [...input].sort(); while (leaves.length > 1) { if (leaves.length % 2 === 1) leaves.push(leaves[leaves.length - 1]!); const next: string[] = []; for (let i = 0; i < leaves.length; i += 2) next.push(hashPair(leaves[i]!, leaves[i + 1]!)); leaves = next; } return leaves[0]!; } function hashPair(left: string, right: string): string { return sha256Hex(left + right); } function sanitizeMetadata>(value: T): T { assertMetadataOnly(value); return JSON.parse(JSON.stringify(value)) as T; } function assertMetadataOnly(value: unknown, path = 'decision'): void { if (value == null) return; if (Array.isArray(value)) { value.forEach((child, index) => assertMetadataOnly(child, `${path}[${index}]`)); return; } if (typeof value !== 'object') return; for (const [key, child] of Object.entries(value as Record)) { if (DATA_PLANE_KEYS.test(key)) { throw new Error('DAG receipt metadata cannot include data-plane field: ' + path + '.' + key); } assertMetadataOnly(child, path + '.' + key); } } function numberOr(value: unknown, fallback: number): number { return Number.isSafeInteger(value) ? value as number : fallback; } function stringOrNull(value: unknown): string | null { return typeof value === 'string' && value ? value : null; } function clampTrust(value: unknown): number { const numeric = typeof value === 'number' && Number.isFinite(value) ? value : 0; return Math.max(0, Math.min(1, numeric)); }