@agentguard-run/spend 0.11.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/decision-log.d.ts +2 -0
- package/dist/decision-log.d.ts.map +1 -1
- package/dist/decision-log.js +11 -0
- package/dist/decision-log.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -3
- package/dist/index.js.map +1 -1
- package/dist/outcomes/runtime.d.ts.map +1 -1
- package/dist/outcomes/runtime.js +6 -1
- package/dist/outcomes/runtime.js.map +1 -1
- package/dist/outcomes/types.d.ts +2 -0
- package/dist/outcomes/types.d.ts.map +1 -1
- package/dist/outcomes/types.js.map +1 -1
- package/dist/receipts/dag.d.ts +95 -0
- package/dist/receipts/dag.d.ts.map +1 -0
- package/dist/receipts/dag.js +501 -0
- package/dist/receipts/dag.js.map +1 -0
- package/dist/spend-guard.d.ts +3 -0
- package/dist/spend-guard.d.ts.map +1 -1
- package/dist/spend-guard.js +21 -1
- package/dist/spend-guard.js.map +1 -1
- package/dist/telemetry.js +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/patents/CLAIM_MAPPING.md +37 -0
- package/package.json +8 -2
- package/src/outcomes/runtime.ts +6 -1
- package/src/outcomes/types.ts +2 -0
- package/src/receipts/dag.ts +540 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import * as ed from '@noble/ed25519';
|
|
2
|
+
import { canonicalJson, computeSignerFingerprint, GENESIS_PREVIOUS_HASH, sha256Hex, verifyEntry } from '../decision-log';
|
|
3
|
+
import type { SignedDecisionLogEntry, SpendDecision } from '../types';
|
|
4
|
+
|
|
5
|
+
export type ReceiptTopology = 'linear' | 'fan_in' | 'fan_out' | 'diamond';
|
|
6
|
+
export type ReceiptCapabilityLevel = 'READ_ONLY' | 'TRANSACT' | 'ADMIN' | 'ORCHESTRATE';
|
|
7
|
+
|
|
8
|
+
export interface ReceiptDagNode {
|
|
9
|
+
version: number;
|
|
10
|
+
sequence: number;
|
|
11
|
+
decision: Record<string, unknown>;
|
|
12
|
+
workflowId: string | null;
|
|
13
|
+
parents: string[];
|
|
14
|
+
topology: ReceiptTopology;
|
|
15
|
+
msgHash: string;
|
|
16
|
+
trustScore: number;
|
|
17
|
+
blocked: boolean;
|
|
18
|
+
capability: ReceiptCapabilityLevel;
|
|
19
|
+
entryHash: string;
|
|
20
|
+
signature: string;
|
|
21
|
+
signerFingerprint: string;
|
|
22
|
+
builderCode?: string | null;
|
|
23
|
+
timestamp: string;
|
|
24
|
+
previousHash?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ReceiptDagEnvelope {
|
|
28
|
+
nodes: ReceiptDagInput[];
|
|
29
|
+
rootId: string;
|
|
30
|
+
workflowId: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ReceiptDagInput = ReceiptDagNode | SignedDecisionLogEntry;
|
|
34
|
+
|
|
35
|
+
export interface ReceiptDagVerificationResult {
|
|
36
|
+
valid: boolean;
|
|
37
|
+
compositeTrust: number;
|
|
38
|
+
depth: number;
|
|
39
|
+
topologySummary: Record<ReceiptTopology, number>;
|
|
40
|
+
reason?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CapabilityGateThresholds {
|
|
44
|
+
READ_ONLY?: { minTrust?: number; minDepth?: number };
|
|
45
|
+
TRANSACT?: { minTrust?: number; minDepth?: number };
|
|
46
|
+
ADMIN?: { minTrust?: number; minDepth?: number };
|
|
47
|
+
ORCHESTRATE?: { minTrust?: number; minDepth?: number };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CapabilityGateResult {
|
|
51
|
+
granted: boolean;
|
|
52
|
+
requestedLevel: ReceiptCapabilityLevel;
|
|
53
|
+
compositeTrust: number;
|
|
54
|
+
depth: number;
|
|
55
|
+
reason?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MerkleProofStep {
|
|
59
|
+
siblingHash: string;
|
|
60
|
+
position: 'left' | 'right';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface MerkleCompressionResult {
|
|
64
|
+
compressed: boolean;
|
|
65
|
+
root: string;
|
|
66
|
+
nodeCount: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_THRESHOLDS: Required<CapabilityGateThresholds> = {
|
|
70
|
+
READ_ONLY: { minTrust: 0.5, minDepth: 1 },
|
|
71
|
+
TRANSACT: { minTrust: 0.72, minDepth: 1 },
|
|
72
|
+
ADMIN: { minTrust: 0.82, minDepth: 2 },
|
|
73
|
+
ORCHESTRATE: { minTrust: 0.9, minDepth: 3 },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const DATA_PLANE_KEYS = /^(prompt|completion|messages|content|source|pii|raw|rawPrompt|rawCompletion|apiKey|secret)$/i;
|
|
77
|
+
|
|
78
|
+
interface NormalizedDagNode extends ReceiptDagNode {
|
|
79
|
+
legacyPreviousHash: string | null;
|
|
80
|
+
legacy: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function signDagNode(args: {
|
|
84
|
+
sequence: number;
|
|
85
|
+
decision: Record<string, unknown> | SpendDecision;
|
|
86
|
+
privateKey: Uint8Array;
|
|
87
|
+
publicKey: Uint8Array;
|
|
88
|
+
parents?: Array<ReceiptDagNode | SignedDecisionLogEntry | string>;
|
|
89
|
+
workflowId?: string | null;
|
|
90
|
+
topology?: ReceiptTopology;
|
|
91
|
+
trustScore?: number;
|
|
92
|
+
blocked?: boolean;
|
|
93
|
+
capability?: ReceiptCapabilityLevel;
|
|
94
|
+
builderCode?: string | null;
|
|
95
|
+
timestamp?: string;
|
|
96
|
+
version?: number;
|
|
97
|
+
}): Promise<ReceiptDagNode> {
|
|
98
|
+
const decision = sanitizeMetadata(args.decision as Record<string, unknown>);
|
|
99
|
+
const parents = (args.parents ?? []).map(parentHash);
|
|
100
|
+
const unsigned = {
|
|
101
|
+
version: args.version ?? 2,
|
|
102
|
+
sequence: args.sequence,
|
|
103
|
+
decision,
|
|
104
|
+
workflowId: args.workflowId ?? stringOrNull(decision.workflowId) ?? stringOrNull(decision.workflow_id),
|
|
105
|
+
parents,
|
|
106
|
+
topology: args.topology ?? topologyForParentCount(parents.length),
|
|
107
|
+
msgHash: msgHashForDecision(decision),
|
|
108
|
+
trustScore: clampTrust(args.trustScore ?? deriveTrustScore(decision)),
|
|
109
|
+
blocked: args.blocked ?? decision.action === 'block',
|
|
110
|
+
capability: args.capability ?? deriveCapability(decision),
|
|
111
|
+
signerFingerprint: computeSignerFingerprint(args.publicKey),
|
|
112
|
+
builderCode: args.builderCode ?? null,
|
|
113
|
+
timestamp: args.timestamp ?? stringOrNull(decision.timestamp) ?? new Date().toISOString(),
|
|
114
|
+
};
|
|
115
|
+
const entryHash = computeDagEntryHash(unsigned);
|
|
116
|
+
const signature = Buffer.from(await ed.signAsync(Buffer.from(entryHash, 'hex'), args.privateKey)).toString('hex');
|
|
117
|
+
return { ...unsigned, entryHash, signature };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function computeDagEntryHash(node: Omit<ReceiptDagNode, 'entryHash' | 'signature'> | Record<string, unknown>): string {
|
|
121
|
+
const { entryHash: _entryHash, signature: _signature, previousHash: _previousHash, legacyPreviousHash: _legacyPreviousHash, legacy: _legacy, ...payload } = node as Record<string, unknown>;
|
|
122
|
+
return sha256Hex(canonicalJson(payload));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function msgHashForDecision(decision: Record<string, unknown>): string {
|
|
126
|
+
assertMetadataOnly(decision);
|
|
127
|
+
return sha256Hex(canonicalJson(decision)).slice(0, 16);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function deriveTrustScore(decision: Record<string, unknown>): number {
|
|
131
|
+
const action = typeof decision.action === 'string' ? decision.action : '';
|
|
132
|
+
const entryType = typeof decision.entryType === 'string' ? decision.entryType : '';
|
|
133
|
+
const reasons = Array.isArray(decision.reasons) ? decision.reasons.map(String).join(' ').toLowerCase() : '';
|
|
134
|
+
if (action === 'block' || reasons.includes('blocked') || reasons.includes('cap reached')) return 0.2;
|
|
135
|
+
if (entryType === 'settlement' && decision.partial === true) return 0.55;
|
|
136
|
+
if (entryType === 'governance') return 0.78;
|
|
137
|
+
if (entryType === 'outcome') return decision.partial === true ? 0.62 : 0.88;
|
|
138
|
+
if (action === 'shadow') return 0.74;
|
|
139
|
+
if (action === 'downgrade') return 0.82;
|
|
140
|
+
if (action === 'allow') return 0.94;
|
|
141
|
+
return 0.7;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function deriveCapability(decision: Record<string, unknown>): ReceiptCapabilityLevel {
|
|
145
|
+
const raw = String(
|
|
146
|
+
decision.capability ??
|
|
147
|
+
decision.capabilityClaim ??
|
|
148
|
+
decision.requiredCapability ??
|
|
149
|
+
(((decision.outcomeReceipt as Record<string, unknown> | undefined)?.metadata as Record<string, unknown> | undefined)?.capability) ??
|
|
150
|
+
'',
|
|
151
|
+
).toLowerCase();
|
|
152
|
+
if (raw.includes('orchestrate')) return 'ORCHESTRATE';
|
|
153
|
+
if (raw.includes('payment')) return 'TRANSACT';
|
|
154
|
+
if (raw.includes('transaction') || raw.includes('transact')) return 'TRANSACT';
|
|
155
|
+
if (raw.includes('admin') || raw.includes('data_write') || raw.includes('write')) return 'ADMIN';
|
|
156
|
+
return 'READ_ONLY';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function verifyReceiptDag(
|
|
160
|
+
nodes: ReceiptDagInput[] | ReceiptDagEnvelope,
|
|
161
|
+
signerKeys: Record<string, string | Uint8Array> | string | Uint8Array,
|
|
162
|
+
): Promise<ReceiptDagVerificationResult> {
|
|
163
|
+
const inputs = Array.isArray(nodes) ? nodes : nodes.nodes;
|
|
164
|
+
if (inputs.length === 0) return { valid: true, compositeTrust: 1, depth: 0, topologySummary: emptyTopologySummary() };
|
|
165
|
+
|
|
166
|
+
let normalized: NormalizedDagNode[];
|
|
167
|
+
try {
|
|
168
|
+
normalized = inputs.map(normalizeDagNode);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return failure(String((err as Error).message || err));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const byHash = new Map<string, NormalizedDagNode>();
|
|
174
|
+
for (const node of normalized) {
|
|
175
|
+
if (byHash.has(node.entryHash)) return failure('DUPLICATE_NODE_HASH');
|
|
176
|
+
byHash.set(node.entryHash, node);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const kahn = topologicalOrder(normalized, byHash);
|
|
180
|
+
if (!kahn.valid) return failure(kahn.reason ?? 'CYCLE_DETECTED');
|
|
181
|
+
|
|
182
|
+
for (const node of normalized) {
|
|
183
|
+
const key = keyForNode(node, signerKeys);
|
|
184
|
+
if (!key) return failure('SIGNER_KEY_NOT_FOUND');
|
|
185
|
+
const publicKey = normalizePublicKey(key);
|
|
186
|
+
if (node.legacy) {
|
|
187
|
+
const legacyOk = await verifyEntry(
|
|
188
|
+
{
|
|
189
|
+
sequence: node.sequence,
|
|
190
|
+
decision: node.decision as unknown as SpendDecision,
|
|
191
|
+
previousHash: node.legacyPreviousHash ?? GENESIS_PREVIOUS_HASH,
|
|
192
|
+
entryHash: node.entryHash,
|
|
193
|
+
signature: node.signature,
|
|
194
|
+
signerFingerprint: node.signerFingerprint,
|
|
195
|
+
},
|
|
196
|
+
publicKey,
|
|
197
|
+
);
|
|
198
|
+
if (!legacyOk) return failure('LEGACY_SIGNATURE_OR_HASH_INVALID');
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
assertMetadataOnly(node.decision);
|
|
202
|
+
const expectedHash = computeDagEntryHash(node);
|
|
203
|
+
if (expectedHash !== node.entryHash) return failure('HASH_INVALID');
|
|
204
|
+
if (computeSignerFingerprint(publicKey) !== node.signerFingerprint) return failure('SIGNER_FINGERPRINT_MISMATCH');
|
|
205
|
+
const ok = await ed.verifyAsync(Buffer.from(node.signature, 'hex'), Buffer.from(node.entryHash, 'hex'), publicKey);
|
|
206
|
+
if (!ok) return failure('SIGNATURE_INVALID');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const diamond = verifyDiamondCorrelation(normalized, byHash);
|
|
210
|
+
if (!diamond.valid) return failure(diamond.reason ?? 'DIAMOND_ANCESTOR_MISMATCH');
|
|
211
|
+
|
|
212
|
+
const depths = computeDepths(kahn.order, byHash);
|
|
213
|
+
const maxDepth = Math.max(...Array.from(depths.values()), 1);
|
|
214
|
+
const topologySummary = summarizeTopology(normalized, byHash);
|
|
215
|
+
return {
|
|
216
|
+
valid: true,
|
|
217
|
+
compositeTrust: Math.min(...normalized.map((node) => node.trustScore)),
|
|
218
|
+
depth: maxDepth,
|
|
219
|
+
topologySummary,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function gateCapability(
|
|
224
|
+
requestedLevel: ReceiptCapabilityLevel,
|
|
225
|
+
dagVerificationResult: ReceiptDagVerificationResult,
|
|
226
|
+
thresholds: CapabilityGateThresholds = {},
|
|
227
|
+
): CapabilityGateResult {
|
|
228
|
+
const merged = { ...DEFAULT_THRESHOLDS[requestedLevel], ...(thresholds[requestedLevel] ?? {}) };
|
|
229
|
+
const threshold = { minTrust: merged.minTrust ?? 0, minDepth: merged.minDepth ?? 0 };
|
|
230
|
+
if (!dagVerificationResult.valid) {
|
|
231
|
+
return {
|
|
232
|
+
granted: false,
|
|
233
|
+
requestedLevel,
|
|
234
|
+
compositeTrust: dagVerificationResult.compositeTrust,
|
|
235
|
+
depth: dagVerificationResult.depth,
|
|
236
|
+
reason: dagVerificationResult.reason ?? 'DAG_VERIFICATION_FAILED',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (dagVerificationResult.compositeTrust < threshold.minTrust) {
|
|
240
|
+
return {
|
|
241
|
+
granted: false,
|
|
242
|
+
requestedLevel,
|
|
243
|
+
compositeTrust: dagVerificationResult.compositeTrust,
|
|
244
|
+
depth: dagVerificationResult.depth,
|
|
245
|
+
reason: 'TRUST_BELOW_THRESHOLD',
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (dagVerificationResult.depth < threshold.minDepth) {
|
|
249
|
+
return {
|
|
250
|
+
granted: false,
|
|
251
|
+
requestedLevel,
|
|
252
|
+
compositeTrust: dagVerificationResult.compositeTrust,
|
|
253
|
+
depth: dagVerificationResult.depth,
|
|
254
|
+
reason: 'DEPTH_BELOW_THRESHOLD',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
granted: true,
|
|
259
|
+
requestedLevel,
|
|
260
|
+
compositeTrust: dagVerificationResult.compositeTrust,
|
|
261
|
+
depth: dagVerificationResult.depth,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function merkleRoot(nodes: ReceiptDagInput[]): string {
|
|
266
|
+
const leaves = nodes.map((node) => normalizeDagNode(node).entryHash).sort();
|
|
267
|
+
return merkleRootFromLeaves(leaves);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function compressReceiptDag(nodes: ReceiptDagInput[], threshold = 10): MerkleCompressionResult {
|
|
271
|
+
return {
|
|
272
|
+
compressed: nodes.length > threshold,
|
|
273
|
+
root: merkleRoot(nodes),
|
|
274
|
+
nodeCount: nodes.length,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function merkleInclusionProof(nodes: ReceiptDagInput[], node: ReceiptDagInput | string): MerkleProofStep[] {
|
|
279
|
+
let leaves = nodes.map((item) => normalizeDagNode(item).entryHash).sort();
|
|
280
|
+
const target = typeof node === 'string' ? node : normalizeDagNode(node).entryHash;
|
|
281
|
+
let index = leaves.indexOf(target);
|
|
282
|
+
if (index < 0) throw new Error('Merkle target node not found');
|
|
283
|
+
const proof: MerkleProofStep[] = [];
|
|
284
|
+
while (leaves.length > 1) {
|
|
285
|
+
if (leaves.length % 2 === 1) leaves = [...leaves, leaves[leaves.length - 1]!];
|
|
286
|
+
const siblingIndex = index % 2 === 0 ? index + 1 : index - 1;
|
|
287
|
+
proof.push({ siblingHash: leaves[siblingIndex]!, position: index % 2 === 0 ? 'right' : 'left' });
|
|
288
|
+
const next: string[] = [];
|
|
289
|
+
for (let i = 0; i < leaves.length; i += 2) next.push(hashPair(leaves[i]!, leaves[i + 1]!));
|
|
290
|
+
index = Math.floor(index / 2);
|
|
291
|
+
leaves = next;
|
|
292
|
+
}
|
|
293
|
+
return proof;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function verifyInclusion(node: ReceiptDagInput | string, proof: MerkleProofStep[], root: string): boolean {
|
|
297
|
+
let hash = typeof node === 'string' ? node : normalizeDagNode(node).entryHash;
|
|
298
|
+
for (const step of proof) {
|
|
299
|
+
hash = step.position === 'left' ? hashPair(step.siblingHash, hash) : hashPair(hash, step.siblingHash);
|
|
300
|
+
}
|
|
301
|
+
return hash === root;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function dagEnvelopeFromEntries(entries: SignedDecisionLogEntry[], workflowId: string | null = null): ReceiptDagEnvelope {
|
|
305
|
+
const nodes = entries.map((entry) => normalizeDagNode(entry));
|
|
306
|
+
const rootId = nodes[nodes.length - 1]?.entryHash ?? GENESIS_PREVIOUS_HASH;
|
|
307
|
+
return { nodes, rootId, workflowId };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function normalizeDagNode(input: ReceiptDagInput): NormalizedDagNode {
|
|
311
|
+
const candidate = input as Partial<ReceiptDagNode> & SignedDecisionLogEntry;
|
|
312
|
+
if ((candidate as { legacy?: boolean }).legacy !== true && Array.isArray(candidate.parents) && candidate.msgHash && candidate.topology) {
|
|
313
|
+
return {
|
|
314
|
+
version: numberOr(candidate.version, 2),
|
|
315
|
+
sequence: candidate.sequence,
|
|
316
|
+
decision: sanitizeMetadata(candidate.decision as Record<string, unknown>),
|
|
317
|
+
workflowId: candidate.workflowId ?? null,
|
|
318
|
+
parents: [...candidate.parents],
|
|
319
|
+
topology: candidate.topology,
|
|
320
|
+
msgHash: candidate.msgHash,
|
|
321
|
+
trustScore: clampTrust(candidate.trustScore),
|
|
322
|
+
blocked: Boolean(candidate.blocked),
|
|
323
|
+
capability: candidate.capability ?? deriveCapability(candidate.decision as Record<string, unknown>),
|
|
324
|
+
entryHash: candidate.entryHash,
|
|
325
|
+
signature: candidate.signature,
|
|
326
|
+
signerFingerprint: candidate.signerFingerprint,
|
|
327
|
+
builderCode: candidate.builderCode ?? null,
|
|
328
|
+
timestamp: candidate.timestamp ?? new Date(0).toISOString(),
|
|
329
|
+
previousHash: candidate.previousHash,
|
|
330
|
+
legacyPreviousHash: null,
|
|
331
|
+
legacy: false,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const previousHash = candidate.previousHash ?? GENESIS_PREVIOUS_HASH;
|
|
335
|
+
const parents = previousHash === GENESIS_PREVIOUS_HASH ? [] : [previousHash];
|
|
336
|
+
const decision = sanitizeMetadata(candidate.decision as unknown as Record<string, unknown>);
|
|
337
|
+
return {
|
|
338
|
+
version: 1,
|
|
339
|
+
sequence: candidate.sequence,
|
|
340
|
+
decision,
|
|
341
|
+
workflowId: stringOrNull(decision.workflowId) ?? stringOrNull(decision.workflow_id),
|
|
342
|
+
parents,
|
|
343
|
+
topology: 'linear',
|
|
344
|
+
msgHash: msgHashForDecision(decision),
|
|
345
|
+
trustScore: deriveTrustScore(decision),
|
|
346
|
+
blocked: decision.action === 'block',
|
|
347
|
+
capability: deriveCapability(decision),
|
|
348
|
+
entryHash: candidate.entryHash,
|
|
349
|
+
signature: candidate.signature,
|
|
350
|
+
signerFingerprint: candidate.signerFingerprint,
|
|
351
|
+
timestamp: stringOrNull(decision.timestamp) ?? new Date(0).toISOString(),
|
|
352
|
+
previousHash,
|
|
353
|
+
legacyPreviousHash: previousHash,
|
|
354
|
+
legacy: true,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function topologicalOrder(nodes: NormalizedDagNode[], byHash: Map<string, NormalizedDagNode>): { valid: boolean; order: string[]; reason?: string } {
|
|
359
|
+
const indegree = new Map<string, number>();
|
|
360
|
+
const children = new Map<string, string[]>();
|
|
361
|
+
for (const node of nodes) {
|
|
362
|
+
const parentRefs = node.parents.filter((parent) => parent !== GENESIS_PREVIOUS_HASH);
|
|
363
|
+
for (const parent of parentRefs) {
|
|
364
|
+
if (!byHash.has(parent)) return { valid: false, order: [], reason: 'MISSING_PARENT' };
|
|
365
|
+
const list = children.get(parent) ?? [];
|
|
366
|
+
list.push(node.entryHash);
|
|
367
|
+
children.set(parent, list);
|
|
368
|
+
}
|
|
369
|
+
indegree.set(node.entryHash, parentRefs.length);
|
|
370
|
+
}
|
|
371
|
+
const queue = Array.from(indegree.entries()).filter(([, degree]) => degree === 0).map(([hash]) => hash);
|
|
372
|
+
const order: string[] = [];
|
|
373
|
+
while (queue.length) {
|
|
374
|
+
const current = queue.shift()!;
|
|
375
|
+
order.push(current);
|
|
376
|
+
for (const child of children.get(current) ?? []) {
|
|
377
|
+
const next = (indegree.get(child) ?? 0) - 1;
|
|
378
|
+
indegree.set(child, next);
|
|
379
|
+
if (next === 0) queue.push(child);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (order.length !== nodes.length) return { valid: false, order, reason: 'CYCLE_DETECTED' };
|
|
383
|
+
return { valid: true, order };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function verifyDiamondCorrelation(nodes: NormalizedDagNode[], byHash: Map<string, NormalizedDagNode>): { valid: boolean; reason?: string } {
|
|
387
|
+
for (const node of nodes) {
|
|
388
|
+
if (node.parents.length < 2) continue;
|
|
389
|
+
const parentNodes = node.parents.map((parent) => byHash.get(parent)).filter(Boolean) as NormalizedDagNode[];
|
|
390
|
+
if (parentNodes.length < 2) continue;
|
|
391
|
+
const ancestorSets = parentNodes.map((parent) => ancestorsOf(parent.entryHash, byHash));
|
|
392
|
+
const common = [...ancestorSets[0]!].filter((hash) => ancestorSets.every((set) => set.has(hash)));
|
|
393
|
+
const isDiamond = node.topology === 'diamond' || common.length > 0;
|
|
394
|
+
if (!isDiamond) continue;
|
|
395
|
+
if (!common.length) return { valid: false, reason: 'DIAMOND_ANCESTOR_MISMATCH' };
|
|
396
|
+
const correlated = common.some((ancestorHash) => {
|
|
397
|
+
const ancestor = byHash.get(ancestorHash);
|
|
398
|
+
return ancestor ? parentNodes.every((parent) => correlatesToAncestor(parent, ancestor)) : false;
|
|
399
|
+
});
|
|
400
|
+
if (!correlated) return { valid: false, reason: 'DIAMOND_MSGHASH_MISMATCH' };
|
|
401
|
+
}
|
|
402
|
+
return { valid: true };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function ancestorsOf(hash: string, byHash: Map<string, NormalizedDagNode>, seen = new Set<string>()): Set<string> {
|
|
406
|
+
const node = byHash.get(hash);
|
|
407
|
+
if (!node) return seen;
|
|
408
|
+
for (const parent of node.parents) {
|
|
409
|
+
if (seen.has(parent)) continue;
|
|
410
|
+
seen.add(parent);
|
|
411
|
+
ancestorsOf(parent, byHash, seen);
|
|
412
|
+
}
|
|
413
|
+
return seen;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function correlatesToAncestor(node: NormalizedDagNode, ancestor: NormalizedDagNode): boolean {
|
|
417
|
+
if (node.msgHash === ancestor.msgHash) return true;
|
|
418
|
+
const flattened = flattenValues(node.decision);
|
|
419
|
+
return flattened.includes(ancestor.msgHash) || flattened.includes(ancestor.entryHash);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function flattenValues(value: unknown): string[] {
|
|
423
|
+
if (value == null) return [];
|
|
424
|
+
if (typeof value === 'string') return [value];
|
|
425
|
+
if (typeof value === 'number' || typeof value === 'boolean') return [String(value)];
|
|
426
|
+
if (Array.isArray(value)) return value.flatMap(flattenValues);
|
|
427
|
+
if (typeof value === 'object') return Object.values(value as Record<string, unknown>).flatMap(flattenValues);
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function computeDepths(order: string[], byHash: Map<string, NormalizedDagNode>): Map<string, number> {
|
|
432
|
+
const depths = new Map<string, number>();
|
|
433
|
+
for (const hash of order) {
|
|
434
|
+
const node = byHash.get(hash)!;
|
|
435
|
+
const parentDepth = node.parents.length
|
|
436
|
+
? Math.max(0, ...node.parents.map((parent) => depths.get(parent) ?? 0))
|
|
437
|
+
: 0;
|
|
438
|
+
depths.set(hash, parentDepth + 1);
|
|
439
|
+
}
|
|
440
|
+
return depths;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function summarizeTopology(nodes: NormalizedDagNode[], byHash: Map<string, NormalizedDagNode>): Record<ReceiptTopology, number> {
|
|
444
|
+
const childCounts = new Map<string, number>();
|
|
445
|
+
for (const node of nodes) {
|
|
446
|
+
for (const parent of node.parents) childCounts.set(parent, (childCounts.get(parent) ?? 0) + 1);
|
|
447
|
+
}
|
|
448
|
+
const summary = emptyTopologySummary();
|
|
449
|
+
for (const node of nodes) {
|
|
450
|
+
let topology: ReceiptTopology = node.topology;
|
|
451
|
+
if (node.parents.length > 1) {
|
|
452
|
+
const parentNodes = node.parents.map((parent) => byHash.get(parent)).filter(Boolean) as NormalizedDagNode[];
|
|
453
|
+
const common = parentNodes.length > 1
|
|
454
|
+
? [...ancestorsOf(parentNodes[0]!.entryHash, byHash)].filter((hash) => parentNodes.every((parent) => ancestorsOf(parent.entryHash, byHash).has(hash)))
|
|
455
|
+
: [];
|
|
456
|
+
topology = common.length ? 'diamond' : 'fan_in';
|
|
457
|
+
} else if (childCounts.get(node.entryHash)! > 1) {
|
|
458
|
+
topology = 'fan_out';
|
|
459
|
+
}
|
|
460
|
+
summary[topology] += 1;
|
|
461
|
+
}
|
|
462
|
+
return summary;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function emptyTopologySummary(): Record<ReceiptTopology, number> {
|
|
466
|
+
return { linear: 0, fan_in: 0, fan_out: 0, diamond: 0 };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function failure(reason: string): ReceiptDagVerificationResult {
|
|
470
|
+
return { valid: false, compositeTrust: 0, depth: 0, topologySummary: emptyTopologySummary(), reason };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function keyForNode(node: ReceiptDagNode, keys: Record<string, string | Uint8Array> | string | Uint8Array): string | Uint8Array | null {
|
|
474
|
+
if (typeof keys === 'string' || keys instanceof Uint8Array) return keys;
|
|
475
|
+
return keys[node.signerFingerprint] ?? null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function normalizePublicKey(key: string | Uint8Array): Uint8Array {
|
|
479
|
+
if (key instanceof Uint8Array) return key;
|
|
480
|
+
const clean = key.replace(/^0x/, '');
|
|
481
|
+
return Uint8Array.from(Buffer.from(clean, 'hex'));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function parentHash(parent: ReceiptDagNode | SignedDecisionLogEntry | string): string {
|
|
485
|
+
return typeof parent === 'string' ? parent : parent.entryHash;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function topologyForParentCount(count: number): ReceiptTopology {
|
|
489
|
+
if (count === 0 || count === 1) return 'linear';
|
|
490
|
+
return 'fan_in';
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function merkleRootFromLeaves(input: string[]): string {
|
|
494
|
+
if (input.length === 0) return sha256Hex('');
|
|
495
|
+
let leaves = [...input].sort();
|
|
496
|
+
while (leaves.length > 1) {
|
|
497
|
+
if (leaves.length % 2 === 1) leaves.push(leaves[leaves.length - 1]!);
|
|
498
|
+
const next: string[] = [];
|
|
499
|
+
for (let i = 0; i < leaves.length; i += 2) next.push(hashPair(leaves[i]!, leaves[i + 1]!));
|
|
500
|
+
leaves = next;
|
|
501
|
+
}
|
|
502
|
+
return leaves[0]!;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function hashPair(left: string, right: string): string {
|
|
506
|
+
return sha256Hex(left + right);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function sanitizeMetadata<T extends Record<string, unknown>>(value: T): T {
|
|
510
|
+
assertMetadataOnly(value);
|
|
511
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function assertMetadataOnly(value: unknown, path = 'decision'): void {
|
|
515
|
+
if (value == null) return;
|
|
516
|
+
if (Array.isArray(value)) {
|
|
517
|
+
value.forEach((child, index) => assertMetadataOnly(child, `${path}[${index}]`));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (typeof value !== 'object') return;
|
|
521
|
+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
522
|
+
if (DATA_PLANE_KEYS.test(key)) {
|
|
523
|
+
throw new Error('DAG receipt metadata cannot include data-plane field: ' + path + '.' + key);
|
|
524
|
+
}
|
|
525
|
+
assertMetadataOnly(child, path + '.' + key);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function numberOr(value: unknown, fallback: number): number {
|
|
530
|
+
return Number.isSafeInteger(value) ? value as number : fallback;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function stringOrNull(value: unknown): string | null {
|
|
534
|
+
return typeof value === 'string' && value ? value : null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function clampTrust(value: unknown): number {
|
|
538
|
+
const numeric = typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
539
|
+
return Math.max(0, Math.min(1, numeric));
|
|
540
|
+
}
|