@mikeargento/bitgraph-audit 0.8.0 → 0.10.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.
Files changed (51) hide show
  1. package/README.md +8 -2
  2. package/dist/audit.d.ts +9 -6
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +34 -8
  5. package/dist/audit.js.map +1 -1
  6. package/dist/ceilings.d.ts.map +1 -1
  7. package/dist/ceilings.js +1 -0
  8. package/dist/ceilings.js.map +1 -1
  9. package/dist/cli.js +109 -21
  10. package/dist/cli.js.map +1 -1
  11. package/dist/exports.d.ts +39 -0
  12. package/dist/exports.d.ts.map +1 -0
  13. package/dist/exports.js +374 -0
  14. package/dist/exports.js.map +1 -0
  15. package/dist/floors.d.ts +23 -0
  16. package/dist/floors.d.ts.map +1 -0
  17. package/dist/floors.js +203 -0
  18. package/dist/floors.js.map +1 -0
  19. package/dist/index.d.ts +6 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/ingest.d.ts +2 -0
  24. package/dist/ingest.d.ts.map +1 -1
  25. package/dist/ingest.js +193 -19
  26. package/dist/ingest.js.map +1 -1
  27. package/dist/report-json.d.ts.map +1 -1
  28. package/dist/report-json.js +30 -0
  29. package/dist/report-json.js.map +1 -1
  30. package/dist/report-md.d.ts.map +1 -1
  31. package/dist/report-md.js +315 -25
  32. package/dist/report-md.js.map +1 -1
  33. package/dist/temporal.d.ts +12 -0
  34. package/dist/temporal.d.ts.map +1 -1
  35. package/dist/temporal.js +245 -41
  36. package/dist/temporal.js.map +1 -1
  37. package/dist/types.d.ts +348 -7
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +3 -2
  40. package/src/__tests__/base-floor.test.ts +405 -0
  41. package/src/audit.ts +36 -8
  42. package/src/ceilings.ts +1 -0
  43. package/src/cli.ts +117 -22
  44. package/src/exports.ts +482 -0
  45. package/src/floors.ts +242 -0
  46. package/src/index.ts +20 -0
  47. package/src/ingest.ts +207 -18
  48. package/src/report-json.ts +30 -0
  49. package/src/report-md.ts +353 -29
  50. package/src/temporal.ts +279 -48
  51. package/src/types.ts +341 -6
package/src/exports.ts ADDED
@@ -0,0 +1,482 @@
1
+ // Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * Exports (bitgraph-export/1) through the audit.
5
+ *
6
+ * An export is one JSON file that, with the file it covers, checks a tree/1
7
+ * BitGraph with nothing of BitGraph's required: the signed proof, the tree's
8
+ * 84-byte root document, one member's evidence (a member export) or the
9
+ * whole list of leaves (the owner's export), the floor block's header, and
10
+ * the ceiling and the settlement, each carried or pending. Ingest finds every
11
+ * export by its format field, wherever it sits; the proof it carries joins
12
+ * the proof analysis like any other. This stage checks each export with
13
+ * verifyExport from bitgraph-verify, once per file in the bundle it covers:
14
+ *
15
+ * member export the files whose SHA-256 is the leaf's artifact digest
16
+ * (the committed bytes) or its origin digest (the original);
17
+ * owner export every file whose SHA-256 is any leaf's artifact or origin;
18
+ * no such file one run without a file: the claims about the file read
19
+ * NOT_CARRIED, which is never a failure.
20
+ *
21
+ * verifyExport's claims are reported exactly as it states them, each with
22
+ * what it rests on. Claims every run states alike are kept once on the
23
+ * export (ExportCheck.claims); each run keeps its own (in practice the ones
24
+ * about its file) with their places in verifyExport's order, and
25
+ * exportRunClaims puts a run's whole list back together. The three time
26
+ * claims are kept apart, never merged: the floor (the committed bytes were
27
+ * finished after the block the proof signs: Ethereum block N for an anchor
28
+ * floor, Base block N for a Base floor, enclave v10; an as-is leaf has no
29
+ * floor), the ceiling on Base (the record existed by Base block B, provisional until B is
30
+ * checked against Base) and the ceiling on Ethereum (the record existed by
31
+ * Ethereum block H). An export with any FALSE claim fails the audit (exit
32
+ * bit 1), as a bad proof does; its attestation claims count, because an
33
+ * export is checked as one self-contained object, as a carrier is.
34
+ *
35
+ * Owner exports at scale: verifyExport rebuilds the whole tree from the
36
+ * owner's list on every call, so checking m files against a list of n
37
+ * leaves as given costs n x m. When the list holds (its claim tree.leaves is
38
+ * TRUE, checked once), each file's member evidence is derived from the list
39
+ * exactly as verifyExport derives it, verifyExport runs on that member form,
40
+ * and the list's claim is put back where verifyExport places it: the same
41
+ * run, for the cost of one path. When the list fails, files are checked as
42
+ * given, within a budget of rebuilt leaves (asGivenLeafBudget).
43
+ *
44
+ * Zero network: no lookup is made unless an embedder passes its own
45
+ * (ExportAuditOptions.lookups). The CLI never does, so every confirmed claim
46
+ * reads UNDETERMINED ("not checked").
47
+ */
48
+
49
+ import {
50
+ base64ToBytes,
51
+ buildTreeMemberEvidence,
52
+ bytesToHex,
53
+ decodeTreeLeaves,
54
+ LEAF_PLACEMENTS,
55
+ MerkleTree,
56
+ parseExport,
57
+ parseTreeMemberEvidence,
58
+ treeLeafHash,
59
+ verifyExport,
60
+ } from "@mikeargento/bitgraph-verify";
61
+ import type { BitGraphExport, ExportLookups, ExportVerifyOptions, ExportVerifyResult, TreeLeaf } from "@mikeargento/bitgraph-verify";
62
+ import { streamArtifactsByHash } from "./ingest.js";
63
+ import type {
64
+ ExportAnalysis,
65
+ ExportCheck,
66
+ ExportClaimRecord,
67
+ ExportCoveredFile,
68
+ ExportFile,
69
+ ExportOwnClaim,
70
+ ExportRun,
71
+ ExportTimes,
72
+ IngestResult,
73
+ } from "./types.js";
74
+
75
+ export interface ExportAuditOptions {
76
+ /** Spec hashes (base64 SHA-256 of SPEC.md) to accept beyond the ones bitgraph-verify knows. */
77
+ extraSpecHashes?: readonly string[];
78
+ /** PCR0 values the reader accepts (claim attestation.pins). Without a list, PCR0 is reported, not judged. */
79
+ pcr0?: readonly string[];
80
+ /** The address an export's Base ceiling must come from. Default: BitGraph's published writer. */
81
+ ceilingWriter?: string;
82
+ /** The Base chain id for an export's ceiling. Default 8453. */
83
+ baseChainId?: number;
84
+ /**
85
+ * An embedder's own chain lookups, for the confirmed claims. The audit
86
+ * makes no network call of its own, and the CLI never sets these.
87
+ */
88
+ lookups?: ExportLookups;
89
+ /**
90
+ * Resource cap: how many leaves may be rebuilt checking one export that
91
+ * carries the owner's list as given, file by file (each run rebuilds the
92
+ * whole list). Spent only when the list does not hold, so no member
93
+ * evidence can be derived from it. At least one file is always checked.
94
+ * Default DEFAULT_AS_GIVEN_LEAF_BUDGET.
95
+ */
96
+ asGivenLeafBudget?: number;
97
+ }
98
+
99
+ /** Default for ExportAuditOptions.asGivenLeafBudget: on the order of 15 s of rebuilding on a laptop. */
100
+ export const DEFAULT_AS_GIVEN_LEAF_BUDGET = 2_000_000;
101
+
102
+ /** The claim verifyExport places the owner's list check before, in the same run. */
103
+ const LIST_CLAIM_ID = "tree.leaves";
104
+ const LIST_CLAIM_BEFORE = "spec.pin";
105
+
106
+ /**
107
+ * A run's whole claim list, in verifyExport's order: the export's common
108
+ * claims with the run's own claims put back at their positions.
109
+ */
110
+ export function exportRunClaims(check: Pick<ExportCheck, "claims">, run: Pick<ExportRun, "claims">): ExportClaimRecord[] {
111
+ const total = check.claims.length + run.claims.length;
112
+ const out: Array<ExportClaimRecord | undefined> = new Array(total);
113
+ for (const own of run.claims) {
114
+ const { position, ...claim } = own;
115
+ if (position >= 0 && position < total && out[position] === undefined) out[position] = claim;
116
+ }
117
+ let next = 0;
118
+ for (let i = 0; i < total; i++) {
119
+ if (out[i] === undefined) out[i] = check.claims[next++];
120
+ }
121
+ return out.filter((c): c is ExportClaimRecord => c !== undefined);
122
+ }
123
+
124
+ /** A run before the export's runs are compared: its whole claim list, claims interned per export. */
125
+ interface FullRun {
126
+ file: ExportCoveredFile | null;
127
+ verdict: ExportRun["verdict"];
128
+ claims: ExportClaimRecord[];
129
+ member: ExportRun["member"];
130
+ times: ExportTimes;
131
+ floorCovers: ExportRun["floorCovers"];
132
+ reading: string;
133
+ }
134
+
135
+ interface Match {
136
+ leafIndex: number | null;
137
+ placement: string | null;
138
+ matchedAs: ExportCoveredFile["matchedAs"];
139
+ nameInExport?: string;
140
+ /** Where the leaf came from: the owner's list (tree.leaves) or the member's evidence (tree.member). */
141
+ source: "list" | "member";
142
+ }
143
+
144
+ interface Plan {
145
+ file: ExportFile;
146
+ exp: BitGraphExport | null;
147
+ kind?: ExportCheck["kind"];
148
+ /** Digest (lowercase hex) to the leaf it matches, first in tree order. */
149
+ digests: Map<string, Match>;
150
+ leaves: TreeLeaf[] | null;
151
+ covered: ExportCoveredFile[];
152
+ /** covered, by SHA-256, with where its leaf came from. */
153
+ coveredByHash: Map<string, { covered: ExportCoveredFile; source: Match["source"] }>;
154
+ runs: Map<string, FullRun>;
155
+ unchecked: Map<string, { file: ExportCoveredFile; reason: string }>;
156
+ /** One object per distinct claim, so identical claims across runs cost one copy. */
157
+ intern: Map<string, ExportClaimRecord>;
158
+ /** Set for an owner's export whose list holds: per-file runs derive member evidence from it. */
159
+ fromList?: { tree: MerkleTree; listClaim: ExportClaimRecord };
160
+ /** As-given per-file runs still allowed for an export carrying a list (each rebuilds the whole list). */
161
+ asGivenLeft: number;
162
+ noFileRun?: FullRun;
163
+ }
164
+
165
+ /**
166
+ * Check every export in the bundle with verifyExport, per covered file.
167
+ * Deterministic: exports in observation order, runs in the order of their
168
+ * covered files (sorted by first path).
169
+ */
170
+ export async function verifyExports(ingest: IngestResult, options: ExportAuditOptions = {}): Promise<ExportAnalysis> {
171
+ const files = ingest.exports ?? [];
172
+ if (files.length === 0) return { checks: [] };
173
+
174
+ const pins: NonNullable<ExportVerifyOptions["pins"]> = {
175
+ ...(options.pcr0 !== undefined ? { pcr0: [...options.pcr0] } : {}),
176
+ ...(options.ceilingWriter !== undefined ? { ceilingWriter: options.ceilingWriter } : {}),
177
+ ...(options.baseChainId !== undefined ? { baseChainId: options.baseChainId } : {}),
178
+ };
179
+ const common: ExportVerifyOptions = {
180
+ pins,
181
+ ...(options.extraSpecHashes !== undefined ? { extraSpecHashes: options.extraSpecHashes } : {}),
182
+ ...(options.lookups !== undefined ? { lookups: options.lookups } : {}),
183
+ };
184
+
185
+ const budget = options.asGivenLeafBudget ?? DEFAULT_AS_GIVEN_LEAF_BUDGET;
186
+ const plans = files.map((file) => planExport(file, ingest, budget));
187
+
188
+ // Owner exports first check their list once: the per-file runs depend on it.
189
+ for (const plan of plans) {
190
+ if (plan.kind !== "owner" || plan.covered.length === 0 || plan.exp === null) continue;
191
+ const r0 = await runExport(plan, plan.exp, null, common);
192
+ const listClaim = r0.claims.find((c) => c.id === LIST_CLAIM_ID);
193
+ if (listClaim?.result === "TRUE" && plan.leaves !== null) {
194
+ plan.fromList = { tree: new MerkleTree(plan.leaves.map(treeLeafHash)), listClaim };
195
+ } else {
196
+ plan.noFileRun = r0;
197
+ }
198
+ }
199
+
200
+ // Every covered file's bytes, read once, whichever exports cover it.
201
+ const byHash = new Map<string, Plan[]>();
202
+ for (const plan of plans) {
203
+ for (const c of plan.covered) {
204
+ const list = byHash.get(c.sha256Hex);
205
+ if (list === undefined) byHash.set(c.sha256Hex, [plan]);
206
+ else list.push(plan);
207
+ }
208
+ }
209
+ for await (const artifact of streamArtifactsByHash(ingest, byHash.keys())) {
210
+ for (const plan of byHash.get(artifact.sha256Hex) ?? []) {
211
+ const entry = plan.coveredByHash.get(artifact.sha256Hex);
212
+ if (entry === undefined || plan.runs.has(artifact.sha256Hex)) continue;
213
+ const run = await runForFile(plan, entry.covered, entry.source, artifact.bytes, common);
214
+ if (run === null) {
215
+ plan.unchecked.set(artifact.sha256Hex, {
216
+ file: entry.covered,
217
+ reason:
218
+ `not checked one by one: checking a file against this export as given rebuilds the owner's whole list ` +
219
+ `(${withCommas(plan.leaves?.length ?? 0)} leaves) each time, and the budget of ${withCommas(budget)} ` +
220
+ `rebuilt leaves ran out; the runs that were made carry the list's own claim (tree.leaves)`,
221
+ });
222
+ } else {
223
+ plan.runs.set(artifact.sha256Hex, run);
224
+ }
225
+ }
226
+ }
227
+
228
+ const checks: ExportCheck[] = [];
229
+ for (const plan of plans) {
230
+ for (const c of plan.covered) {
231
+ if (!plan.runs.has(c.sha256Hex) && !plan.unchecked.has(c.sha256Hex)) {
232
+ plan.unchecked.set(c.sha256Hex, { file: c, reason: "not checked: the file's bytes could not be re-read from the bundle" });
233
+ }
234
+ }
235
+ const runs = plan.covered.map((c) => plan.runs.get(c.sha256Hex)).filter((r): r is FullRun => r !== undefined);
236
+ if (runs.length === 0) {
237
+ runs.push(plan.noFileRun ?? (await runExport(plan, plan.exp ?? plan.file.json, null, common)));
238
+ }
239
+ checks.push(assemble(plan, runs));
240
+ }
241
+ return { checks };
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Planning: which bundle files an export covers
246
+ // ---------------------------------------------------------------------------
247
+
248
+ function planExport(file: ExportFile, ingest: IngestResult, budget: number): Plan {
249
+ const plan: Plan = {
250
+ file,
251
+ exp: null,
252
+ digests: new Map(),
253
+ leaves: null,
254
+ covered: [],
255
+ coveredByHash: new Map(),
256
+ runs: new Map(),
257
+ unchecked: new Map(),
258
+ intern: new Map(),
259
+ asGivenLeft: Number.POSITIVE_INFINITY,
260
+ };
261
+ if (file.status !== "ok") return plan;
262
+ const exp = parseExport(file.json);
263
+ if (exp === null) return plan;
264
+ plan.exp = exp;
265
+
266
+ const tree = exp.tree as unknown as Record<string, unknown>;
267
+ const hasMember = tree["member"] !== undefined;
268
+ const hasLeaves = tree["leaves"] !== undefined;
269
+ plan.kind = hasLeaves ? "owner" : hasMember ? "member" : "root-only";
270
+
271
+ if (hasLeaves) {
272
+ const bytes = typeof tree["leaves"] === "string" ? base64ToBytes(tree["leaves"]) : null;
273
+ const leaves = bytes === null ? null : decodeTreeLeaves(bytes);
274
+ plan.leaves = leaves;
275
+ if (leaves !== null) {
276
+ const names = tree["names"];
277
+ const named = Array.isArray(names) && names.length === leaves.length && names.every((n) => typeof n === "string") ? (names as string[]) : null;
278
+ leaves.forEach((leaf, i) => addLeaf(plan.digests, leaf, i, named?.[i], "list"));
279
+ // Checking as given rebuilds the whole list per file.
280
+ plan.asGivenLeft = Math.max(1, Math.floor(budget / Math.max(1, leaves.length)));
281
+ }
282
+ }
283
+ if (hasMember) {
284
+ // After the list: a digest the list already places keeps the list's leaf, as verifyExport does.
285
+ const ev = parseTreeMemberEvidence(tree["member"]);
286
+ if (ev !== null) addLeaf(plan.digests, ev.leaf, ev.index, undefined, "member");
287
+ }
288
+
289
+ for (const artifact of ingest.artifacts) {
290
+ const match = plan.digests.get(artifact.sha256Hex);
291
+ if (match === undefined) continue;
292
+ const covered: ExportCoveredFile = {
293
+ sha256Hex: artifact.sha256Hex,
294
+ paths: [...artifact.paths],
295
+ byteLength: artifact.byteLength,
296
+ leafIndex: match.leafIndex,
297
+ placement: match.placement,
298
+ matchedAs: match.matchedAs,
299
+ ...(match.nameInExport !== undefined ? { nameInExport: match.nameInExport } : {}),
300
+ };
301
+ plan.covered.push(covered);
302
+ plan.coveredByHash.set(artifact.sha256Hex, { covered, source: match.source });
303
+ }
304
+ plan.covered.sort((a, b) => cmp(a.paths[0] ?? "", b.paths[0] ?? "") || cmp(a.sha256Hex, b.sha256Hex));
305
+ return plan;
306
+ }
307
+
308
+ /** Record a leaf's two digests, keeping the first leaf in tree order for each (verifyExport's findIndex). */
309
+ function addLeaf(digests: Map<string, Match>, leaf: TreeLeaf, index: number, name: string | undefined, source: Match["source"]): void {
310
+ const artifact = bytesToHex(leaf.artifact);
311
+ const origin = bytesToHex(leaf.origin);
312
+ const placement = LEAF_PLACEMENTS[leaf.placement] ?? null;
313
+ const at = { leafIndex: index, placement, source, ...(name !== undefined ? { nameInExport: name } : {}) };
314
+ if (!digests.has(artifact)) digests.set(artifact, { ...at, matchedAs: artifact === origin ? "as-is" : "committed-bytes" });
315
+ if (origin !== artifact && !digests.has(origin)) digests.set(origin, { ...at, matchedAs: "original" });
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Runs
320
+ // ---------------------------------------------------------------------------
321
+
322
+ async function runForFile(
323
+ plan: Plan,
324
+ covered: ExportCoveredFile,
325
+ source: Match["source"],
326
+ bytes: Uint8Array,
327
+ common: ExportVerifyOptions
328
+ ): Promise<FullRun | null> {
329
+ const exp = plan.exp!;
330
+ if (plan.fromList !== undefined && plan.leaves !== null && source === "list" && covered.leafIndex !== null) {
331
+ // The owner's list holds: derive this file's member evidence from it
332
+ // exactly as verifyExport does internally (the first leaf in tree order
333
+ // whose committed or original digest is the file's, its path in the tree
334
+ // the whole list builds), run on that member form, and put the list's
335
+ // claim back where verifyExport places it. A test holds this equal to the
336
+ // as-given run.
337
+ const k = covered.leafIndex;
338
+ const evidence = buildTreeMemberEvidence(plan.leaves[k]!, k, plan.leaves.length, plan.fromList.tree.path(k));
339
+ const memberForm = { ...exp, tree: { rootDocument: exp.tree.rootDocument, member: evidence } };
340
+ const run = await runExport(plan, memberForm, covered, common, bytes);
341
+ const at = run.claims.findIndex((c) => c.id === LIST_CLAIM_BEFORE);
342
+ run.claims.splice(at < 0 ? run.claims.length : at, 0, plan.fromList.listClaim);
343
+ return run;
344
+ }
345
+ if (plan.leaves !== null) {
346
+ if (plan.asGivenLeft <= 0) return null;
347
+ plan.asGivenLeft--;
348
+ }
349
+ return runExport(plan, exp, covered, common, bytes);
350
+ }
351
+
352
+ /** One verifyExport call. A verifier that throws fails the export closed. */
353
+ async function runExport(plan: Plan, input: unknown, file: ExportCoveredFile | null, common: ExportVerifyOptions, bytes?: Uint8Array): Promise<FullRun> {
354
+ let r: ExportVerifyResult;
355
+ try {
356
+ r = await verifyExport(input, bytes !== undefined ? { ...common, bytes } : common);
357
+ } catch (err) {
358
+ const detail = `verifyExport could not complete on this export (${err instanceof Error ? err.message : String(err)}); the audit fails it closed`;
359
+ return {
360
+ file,
361
+ verdict: "FALSE",
362
+ claims: [intern(plan, { id: "verifier", name: "The verifier completed its checks", result: "FALSE", restsOn: "", detail, level: "offline" })],
363
+ member: null,
364
+ times: { floor: null, ceilingBase: null, ceilingEthereum: null },
365
+ floorCovers: null,
366
+ reading: "This export could not be checked: the verifier stopped on it. The audit counts that as a failure.",
367
+ };
368
+ }
369
+ const claims = r.claims.map((c) =>
370
+ intern(plan, { id: c.id, name: c.name, result: c.result, restsOn: c.restsOn, detail: c.detail, level: c.level })
371
+ );
372
+ const isMember = claims.some((c) => c.id === "bytes.member" && c.result === "TRUE");
373
+ return {
374
+ file,
375
+ verdict: r.verdict,
376
+ claims,
377
+ member: r.member === null ? null : { ...r.member },
378
+ times: copyTimes(r.times),
379
+ floorCovers: file === null || !isMember || r.member === null ? null : r.member.placement === "as-is" ? "record" : "content",
380
+ reading: r.reading,
381
+ };
382
+ }
383
+
384
+ function intern(plan: Plan, claim: ExportClaimRecord): ExportClaimRecord {
385
+ const key = claimKey(claim);
386
+ const known = plan.intern.get(key);
387
+ if (known !== undefined) return known;
388
+ plan.intern.set(key, claim);
389
+ return claim;
390
+ }
391
+
392
+ function claimKey(c: ExportClaimRecord): string {
393
+ return `${c.id}\u0000${c.result}\u0000${c.level}\u0000${c.restsOn}\u0000${c.detail}`;
394
+ }
395
+
396
+ function copyTimes(t: ExportVerifyResult["times"]): ExportTimes {
397
+ return {
398
+ floor:
399
+ t.floor === null
400
+ ? null
401
+ : { ...(t.floor.chain === "base" ? { chain: "base" as const } : {}), blockNumber: t.floor.blockNumber, blockHash: t.floor.blockHash, blockTimestamp: t.floor.blockTimestamp },
402
+ ceilingBase:
403
+ t.ceilingBase === null
404
+ ? null
405
+ : { blockNumber: t.ceilingBase.blockNumber, blockHash: t.ceilingBase.blockHash, blockTimestamp: t.ceilingBase.blockTimestamp, provisional: t.ceilingBase.provisional },
406
+ ceilingEthereum:
407
+ t.ceilingEthereum === null
408
+ ? null
409
+ : { blockNumber: t.ceilingEthereum.blockNumber, blockHash: t.ceilingEthereum.blockHash, blockTimestamp: t.ceilingEthereum.blockTimestamp },
410
+ };
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // The check
415
+ // ---------------------------------------------------------------------------
416
+
417
+ function assemble(plan: Plan, full: FullRun[]): ExportCheck {
418
+ const { file } = plan;
419
+
420
+ // Claims every run states alike (interned: the same object), in the first run's order.
421
+ const inRuns = new Map<ExportClaimRecord, number>();
422
+ for (const run of full) for (const c of new Set(run.claims)) inRuns.set(c, (inRuns.get(c) ?? 0) + 1);
423
+ const isCommon = (c: ExportClaimRecord) => inRuns.get(c) === full.length;
424
+ const claims = full[0]!.claims.filter(isCommon);
425
+ const runs: ExportRun[] = full.map((run) => ({
426
+ file: run.file,
427
+ verdict: run.verdict,
428
+ claims: run.claims.flatMap((c, position): ExportOwnClaim[] => (isCommon(c) ? [] : [{ ...c, position }])),
429
+ member: run.member,
430
+ floorCovers: run.floorCovers,
431
+ reading: run.reading,
432
+ }));
433
+
434
+ const failedClaims: string[] = [];
435
+ for (const run of full) {
436
+ for (const c of run.claims) if (c.result === "FALSE" && !failedClaims.includes(c.id)) failedClaims.push(c.id);
437
+ }
438
+ const verdict: ExportCheck["verdict"] =
439
+ failedClaims.length > 0 || full.some((r) => r.verdict === "FALSE")
440
+ ? "FALSE"
441
+ : full.some((r) => r.verdict === "UNDETERMINED")
442
+ ? "UNDETERMINED"
443
+ : "TRUE";
444
+ const status: ExportCheck["status"] = file.status === "ok" ? "checked" : file.status;
445
+ const reason =
446
+ file.status === "malformed"
447
+ ? `declares ${file.format} but lacks an export's structure (a proof object and tree.rootDocument)`
448
+ : file.status === "unsupported-format"
449
+ ? `format "${file.format}" is not bitgraph-export/1`
450
+ : file.status === "too-large"
451
+ ? "opens an export but is larger than the audit's cap for one export's JSON (IngestLimits.maxExportJsonBytes)"
452
+ : undefined;
453
+ return {
454
+ path: file.path,
455
+ fileSha256Hex: file.fileSha256Hex,
456
+ format: file.format,
457
+ status,
458
+ ...(reason !== undefined ? { reason } : {}),
459
+ ...(plan.kind !== undefined ? { kind: plan.kind } : {}),
460
+ ...(file.proofHash !== undefined ? { proofHash: file.proofHash } : {}),
461
+ // A rejected file is never judged TRUE; its exit bit comes from its status.
462
+ verdict: status === "checked" ? verdict : verdict === "FALSE" ? "FALSE" : "UNDETERMINED",
463
+ failedClaims,
464
+ // The time claims do not depend on the file, so every run that completed states the same.
465
+ times: (full.find((r) => !r.claims.some((c) => c.id === "verifier")) ?? full[0]!).times,
466
+ claims,
467
+ files: plan.covered,
468
+ runs,
469
+ ...(plan.kind === "owner" && plan.covered.length > 0 ? { runMode: plan.fromList !== undefined ? ("member-from-list" as const) : ("as-given" as const) } : {}),
470
+ ...(plan.unchecked.size > 0
471
+ ? { unchecked: plan.covered.filter((c) => plan.unchecked.has(c.sha256Hex)).map((c) => plan.unchecked.get(c.sha256Hex)!) }
472
+ : {}),
473
+ };
474
+ }
475
+
476
+ function withCommas(n: number): string {
477
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
478
+ }
479
+
480
+ function cmp(a: string, b: string): number {
481
+ return a < b ? -1 : a > b ? 1 : 0;
482
+ }