@mikeargento/bitgraph-player 0.3.0 → 0.4.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/dist/check.js ADDED
@@ -0,0 +1,640 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+ import { auditIngest, AUDIT_VERSION } from "@mikeargento/bitgraph-audit";
3
+ import { kleeneAll } from "./logic.js";
4
+ import { PLAYER_VERSION } from "./verdict.js";
5
+ // ---------------------------------------------------------------------------
6
+ // Declared knowledge: published enclave measurements
7
+ // ---------------------------------------------------------------------------
8
+ /**
9
+ * Published BitGraph enclave PCR0 measurements, per PINS.md, oldest first.
10
+ * Each remains correct for proofs minted during its own period; a proof
11
+ * carries its own measurement, so nothing here needs to be "current" for
12
+ * an old proof to check. Lowercase hex, exactly as attestations report.
13
+ */
14
+ export const KNOWN_ENCLAVE_MEASUREMENTS = [
15
+ {
16
+ pcr0: "8530a6399399c4f23d89f5a1faa2e8bf2e09a5959f117070fca08148377f92c902c695fc926c17f67f35f110327dca92",
17
+ label: "genesis",
18
+ period: "2026-05-15 to 2026-06-27",
19
+ },
20
+ {
21
+ pcr0: "bb9dd158703603ec222fe565495ceaa7edc08f665da5c1cddad91442ac2211731390267036d79deb720d13fb704f648a",
22
+ label: "enclave v2 (reproducible)",
23
+ period: "2026-06-27 to 2026-07-05",
24
+ },
25
+ {
26
+ pcr0: "e2fccbae77ee40aac4830e84f195e05d69eb4547bbd961f4d3459feba10807140424aca42ad03810354982598c86b9cb",
27
+ label: "enclave v4 (reproducible)",
28
+ period: "2026-07-05 to 2026-07-29",
29
+ },
30
+ {
31
+ pcr0: "6483cedffed74680ffb287507744a398b288c3fb943eb3f2e4fe889f8b60b3d575ad8942350360b69a1bd7bf713df27f",
32
+ label: "enclave v5 (reproducible)",
33
+ period: "2026-07-29 onward",
34
+ },
35
+ ];
36
+ // ---------------------------------------------------------------------------
37
+ // Anomaly classification for excerpts
38
+ // ---------------------------------------------------------------------------
39
+ /**
40
+ * Codes that mean "the bundle is an excerpt", not "the chain is wrong":
41
+ * a recording whose predecessor is not supplied, positions between the
42
+ * supplied recordings that are not supplied, an epoch link whose other end
43
+ * is not supplied. Reported as notes. Everything else classifyAnomalies
44
+ * emits is a contradiction among the supplied proofs and surfaces as FALSE.
45
+ */
46
+ const EXCERPT_NORMAL_CODES = new Set([
47
+ "unexplained-counter-positions",
48
+ "chain-break-missing",
49
+ "epochlink-terminal-missing",
50
+ "epochlink-dangling",
51
+ ]);
52
+ // ---------------------------------------------------------------------------
53
+ // Public API
54
+ // ---------------------------------------------------------------------------
55
+ /**
56
+ * Check an already-ingested bundle: runs the audit's pure tail (no
57
+ * filesystem, no network) and builds the report. Both the CLI and the
58
+ * browser page call this, so they cannot drift.
59
+ */
60
+ export async function checkIngest(ingest, options) {
61
+ const audit = await auditIngest(ingest, { startedAt: "" });
62
+ return buildCheckReport(audit, options);
63
+ }
64
+ /** The pure report builder over an AuditResult. */
65
+ export function buildCheckReport(audit, options) {
66
+ const webCrypto = options?.webCryptoAvailable ?? true;
67
+ const anchorByHash = new Map(audit.anchors.anchors.map((a) => [a.proofHash, a]));
68
+ const attestationByHash = new Map(audit.attestations.records.map((r) => [r.proofHash, r]));
69
+ const segmentByHash = new Map();
70
+ for (const segment of audit.temporal.segments) {
71
+ for (const hash of segment.memberProofHashes)
72
+ segmentByHash.set(hash, segment);
73
+ }
74
+ const witnessOutcomesByAnchor = new Map();
75
+ for (const outcome of audit.witnesses.outcomes) {
76
+ if (outcome.anchorProofHash === undefined)
77
+ continue;
78
+ const list = witnessOutcomesByAnchor.get(outcome.anchorProofHash) ?? [];
79
+ list.push(outcome);
80
+ witnessOutcomesByAnchor.set(outcome.anchorProofHash, list);
81
+ }
82
+ const artifactPathByProof = new Map();
83
+ for (const artifact of audit.ingest.artifacts) {
84
+ for (const hash of artifact.matchedProofHashes) {
85
+ if (!artifactPathByProof.has(hash) && artifact.paths[0] !== undefined) {
86
+ artifactPathByProof.set(hash, artifact.paths[0]);
87
+ }
88
+ }
89
+ }
90
+ const recordings = [];
91
+ const anchors = [];
92
+ for (const proof of audit.ingest.proofs) {
93
+ const anchor = anchorByHash.get(proof.proofHash);
94
+ if (anchor !== undefined) {
95
+ anchors.push(buildAnchor(proof, anchor, witnessOutcomesByAnchor.get(proof.proofHash) ?? []));
96
+ }
97
+ else {
98
+ recordings.push(buildRecording(proof, attestationByHash.get(proof.proofHash), segmentByHash.get(proof.proofHash), artifactPathByProof.get(proof.proofHash), webCrypto));
99
+ }
100
+ }
101
+ sortByPosition(recordings);
102
+ sortByPosition(anchors);
103
+ const contradictions = collectContradictions(audit);
104
+ const notes = collectNotes(audit, recordings, anchors);
105
+ const notChecked = collectNotChecked(anchors.length > 0);
106
+ const allLines = [
107
+ ...recordings.map((r) => r.result),
108
+ ...anchors.map((a) => a.result),
109
+ ...contradictions.map((c) => c.result),
110
+ ];
111
+ const result = allLines.length === 0 ? "UNDETERMINED" : kleeneAll(allLines);
112
+ return {
113
+ check: "bitgraph-check/1",
114
+ result,
115
+ summary: summarize(result, recordings, anchors, contradictions),
116
+ recordings,
117
+ anchors,
118
+ contradictions,
119
+ notes,
120
+ notChecked,
121
+ evaluator: { name: "bitgraph-player", version: PLAYER_VERSION, audit: AUDIT_VERSION },
122
+ network: "none",
123
+ };
124
+ }
125
+ /**
126
+ * Causal display order: by epoch id, then commit counter as an integer.
127
+ * Entries without a parseable counter keep observation order after those
128
+ * with one. Deterministic, and it puts the before-anchor before the
129
+ * after-anchor regardless of file names.
130
+ */
131
+ function sortByPosition(items) {
132
+ const key = (x) => [
133
+ x.epochId ?? "",
134
+ x.counter !== undefined && /^[0-9]+$/.test(x.counter) ? BigInt(x.counter) : undefined,
135
+ ];
136
+ items.sort((a, b) => {
137
+ const [ae, ac] = key(a);
138
+ const [be, bc] = key(b);
139
+ if (ae !== be)
140
+ return ae < be ? -1 : 1;
141
+ if (ac === undefined || bc === undefined)
142
+ return ac === undefined ? (bc === undefined ? 0 : 1) : -1;
143
+ return ac < bc ? -1 : ac > bc ? 1 : 0;
144
+ });
145
+ }
146
+ // ---------------------------------------------------------------------------
147
+ // Recordings
148
+ // ---------------------------------------------------------------------------
149
+ function buildRecording(proof, attestation, segment, filePath, webCrypto) {
150
+ const lines = [];
151
+ const v = proof.verification;
152
+ const digestB64 = proof.proof.artifact.digestB64;
153
+ // file: the bytes in hand hash to the recorded digest.
154
+ if (v?.tier === "full") {
155
+ lines.push({
156
+ name: "file",
157
+ result: v.status === "verified" || v.status === "failed" ? "TRUE" : "UNDETERMINED",
158
+ detail: v.status === "verified" || v.status === "failed"
159
+ ? `${filePath ?? "the matched file"} hashes to the recorded digest ${digestB64}`
160
+ : `no file in this bundle hashes to the recorded digest ${digestB64}`,
161
+ });
162
+ }
163
+ else {
164
+ lines.push({
165
+ name: "file",
166
+ result: "UNDETERMINED",
167
+ detail: `no file in this bundle hashes to the recorded digest ${digestB64}; the recording cannot be bound to bytes in hand`,
168
+ });
169
+ }
170
+ // signature: the proof body verifies (structure, slot binding, Ed25519).
171
+ // At full tier a matched artifact always hashes to the digest (matching
172
+ // is content-addressed), so a full-tier failure is a signature or
173
+ // structure failure, never a digest mismatch.
174
+ if (v === undefined) {
175
+ lines.push({ name: "signature", result: "UNDETERMINED", detail: "the proof was not verified" });
176
+ }
177
+ else if (v.status === "failed") {
178
+ lines.push({
179
+ name: "signature",
180
+ result: "FALSE",
181
+ detail: `the proof does not verify: ${v.reason ?? "unspecified failure"}`,
182
+ });
183
+ }
184
+ else {
185
+ lines.push({
186
+ name: "signature",
187
+ result: "TRUE",
188
+ detail: `Ed25519 signature and slot binding verify under signer key ${shortB64(proof.publicKeyB64)}`,
189
+ });
190
+ }
191
+ // attestation: the AWS Nitro document validates, is bound to THIS proof
192
+ // (user_data), and attests the PCR0 the signed body declares.
193
+ const att = attestationLine(attestation, webCrypto);
194
+ lines.push(att.line);
195
+ // enclave: the attested PCR0 is a published BitGraph measurement.
196
+ lines.push(enclaveLine(att.attestedPcr0, att.line.result));
197
+ const result = kleeneAll(lines.map((l) => l.result));
198
+ return {
199
+ proofHash: proof.proofHash,
200
+ digestB64,
201
+ ...(proof.epochId !== undefined ? { epochId: proof.epochId } : {}),
202
+ chainId: proof.chainId,
203
+ ...(proof.counter !== undefined ? { counter: proof.counter } : {}),
204
+ ...(proof.slotCounter !== undefined ? { slotCounter: proof.slotCounter } : {}),
205
+ ...(proof.publicKeyB64 !== undefined ? { publicKeyB64: proof.publicKeyB64 } : {}),
206
+ ...(filePath !== undefined && v?.tier === "full" ? { filePath } : {}),
207
+ lines,
208
+ result,
209
+ bounds: boundsFor(segment),
210
+ };
211
+ }
212
+ function attestationLine(record, webCrypto) {
213
+ if (record === undefined || !record.documentPresent) {
214
+ return {
215
+ line: {
216
+ name: "attestation",
217
+ result: "UNDETERMINED",
218
+ detail: "no attestation document in this proof",
219
+ },
220
+ };
221
+ }
222
+ if (!webCrypto) {
223
+ return {
224
+ line: {
225
+ name: "attestation",
226
+ result: "UNDETERMINED",
227
+ detail: "attestation not checked: this environment cannot verify ECDSA P-384 (no WebCrypto); open the verifier from a file or https page, or run bitgraph-play check",
228
+ },
229
+ };
230
+ }
231
+ if (!record.documentValidated) {
232
+ return {
233
+ line: {
234
+ name: "attestation",
235
+ result: "FALSE",
236
+ detail: `attestation document does not validate: ${record.validationFailure ?? "unspecified failure"}`,
237
+ },
238
+ };
239
+ }
240
+ if (record.userDataBoundToProof !== true) {
241
+ return {
242
+ line: {
243
+ name: "attestation",
244
+ result: "FALSE",
245
+ detail: "attestation document validates but its user_data is not this proof: the document belongs to some other proof",
246
+ },
247
+ attestedPcr0: record.attestedPcr0,
248
+ };
249
+ }
250
+ if (record.pcr0MatchesDeclared !== true) {
251
+ return {
252
+ line: {
253
+ name: "attestation",
254
+ result: "FALSE",
255
+ detail: `attestation validates and binds this proof, but its PCR0 ${shortHex(record.attestedPcr0)} is not the measurement the signed body declares ${shortHex(record.declaredMeasurement)}`,
256
+ },
257
+ attestedPcr0: record.attestedPcr0,
258
+ };
259
+ }
260
+ return {
261
+ line: {
262
+ name: "attestation",
263
+ result: "TRUE",
264
+ detail: `AWS Nitro attestation validates to the AWS root, binds this exact proof (user_data), and attests PCR0 ${shortHex(record.attestedPcr0)}`,
265
+ },
266
+ attestedPcr0: record.attestedPcr0,
267
+ };
268
+ }
269
+ function enclaveLine(attestedPcr0, attestationResult) {
270
+ if (attestationResult !== "TRUE" || attestedPcr0 === undefined) {
271
+ return {
272
+ name: "enclave",
273
+ result: "UNDETERMINED",
274
+ detail: "enclave identity rests on a validated attestation, which this recording does not have here",
275
+ };
276
+ }
277
+ const known = KNOWN_ENCLAVE_MEASUREMENTS.find((m) => m.pcr0 === attestedPcr0.toLowerCase());
278
+ if (known === undefined) {
279
+ return {
280
+ name: "enclave",
281
+ result: "UNDETERMINED",
282
+ detail: `PCR0 ${shortHex(attestedPcr0)} is not among the BitGraph enclave measurements this verifier knows (player ${PLAYER_VERSION}); compare it against the measurements published at bitgraph.ing/docs/self-host-tee`,
283
+ };
284
+ }
285
+ return {
286
+ name: "enclave",
287
+ result: "TRUE",
288
+ detail: `PCR0 ${shortHex(attestedPcr0)} is the published BitGraph ${known.label} measurement (${known.period})`,
289
+ };
290
+ }
291
+ function boundsFor(segment) {
292
+ if (segment === undefined) {
293
+ return { status: "unanchored", detail: "no verified Ethereum anchor bounds this recording in this bundle" };
294
+ }
295
+ const lower = tightest(segment.lowerBounds, "not-before");
296
+ const upper = tightest(segment.upperBounds, "not-after");
297
+ const notBefore = lower === undefined ? undefined : toBound(lower);
298
+ const notAfter = upper === undefined ? undefined : toBound(upper);
299
+ const status = notBefore !== undefined && notAfter !== undefined
300
+ ? "bracketed"
301
+ : notBefore !== undefined
302
+ ? "lower-bounded"
303
+ : notAfter !== undefined
304
+ ? "upper-bounded"
305
+ : "unanchored";
306
+ const detail = status === "unanchored"
307
+ ? "no verified Ethereum anchor bounds this recording in this bundle"
308
+ : `recorded ${boundsPhrase(status, notBefore, notAfter)}`;
309
+ return {
310
+ status,
311
+ ...(notBefore !== undefined ? { notBefore } : {}),
312
+ ...(notAfter !== undefined ? { notAfter } : {}),
313
+ detail,
314
+ };
315
+ }
316
+ /** "after Ethereum block A and before block B (both headers verified)…", shared by bounds.detail and the summary. */
317
+ function boundsPhrase(status, notBefore, notAfter) {
318
+ switch (status) {
319
+ case "bracketed":
320
+ return (`after Ethereum block ${blockRef(notBefore)} and before block ${blockRef(notAfter)} (both headers verified in this bundle)` +
321
+ weakerSuffix(notBefore.weaker || notAfter.weaker));
322
+ case "lower-bounded":
323
+ return `after Ethereum block ${blockRef(notBefore)} (header verified in this bundle); no verified upper bound here` + weakerSuffix(notBefore.weaker);
324
+ case "upper-bounded":
325
+ return `before Ethereum block ${blockRef(notAfter)} (header verified in this bundle); no verified lower bound here` + weakerSuffix(notAfter.weaker);
326
+ default:
327
+ return "with no verified Ethereum bound in this bundle";
328
+ }
329
+ }
330
+ /**
331
+ * The tightest bound of a kind: for not-before the LARGEST block number
332
+ * (latest verified anchor known to precede), for not-after the SMALLEST.
333
+ * Ties resolve by preferring chain-link evidence over counter-order.
334
+ */
335
+ function tightest(bounds, kind) {
336
+ const ofKind = bounds.filter((b) => b.kind === kind);
337
+ if (ofKind.length === 0)
338
+ return undefined;
339
+ const sorted = [...ofKind].sort((a, b) => {
340
+ const an = a.blockNumber !== undefined ? BigInt(a.blockNumber) : BigInt(a.timestamp);
341
+ const bn = b.blockNumber !== undefined ? BigInt(b.blockNumber) : BigInt(b.timestamp);
342
+ if (an !== bn)
343
+ return kind === "not-before" ? (an > bn ? -1 : 1) : an < bn ? -1 : 1;
344
+ if (a.weaker !== b.weaker)
345
+ return a.weaker ? 1 : -1;
346
+ return 0;
347
+ });
348
+ return sorted[0];
349
+ }
350
+ function toBound(b) {
351
+ return {
352
+ ...(b.blockNumber !== undefined ? { blockNumber: b.blockNumber } : {}),
353
+ blockHash: b.blockHash,
354
+ timestamp: b.timestamp,
355
+ anchorProofHash: b.anchorProofHash,
356
+ evidence: b.evidence,
357
+ weaker: b.weaker,
358
+ };
359
+ }
360
+ /** The headline form of the bounds: one clause, no qualifiers (those live in bounds.detail). */
361
+ function shortBoundsPhrase(b) {
362
+ switch (b.status) {
363
+ case "bracketed":
364
+ return `between Ethereum blocks ${blockRef(b.notBefore)} and ${blockRef(b.notAfter)}`;
365
+ case "lower-bounded":
366
+ return `after Ethereum block ${blockRef(b.notBefore)}`;
367
+ case "upper-bounded":
368
+ return `before Ethereum block ${blockRef(b.notAfter)}`;
369
+ default:
370
+ return "with no Ethereum bound in this bundle";
371
+ }
372
+ }
373
+ function blockRef(b) {
374
+ return b.blockNumber !== undefined ? b.blockNumber : b.blockHash;
375
+ }
376
+ function weakerSuffix(weaker) {
377
+ return weaker
378
+ ? "; ordered by counter position within the epoch, since the recordings between are not in this bundle"
379
+ : "";
380
+ }
381
+ // ---------------------------------------------------------------------------
382
+ // Anchors
383
+ // ---------------------------------------------------------------------------
384
+ function buildAnchor(proof, anchor, outcomes) {
385
+ const lines = [];
386
+ const v = proof.verification;
387
+ if (v === undefined) {
388
+ lines.push({ name: "signature", result: "UNDETERMINED", detail: "the anchor proof was not verified" });
389
+ }
390
+ else if (v.status === "failed") {
391
+ lines.push({
392
+ name: "signature",
393
+ result: "FALSE",
394
+ detail: `the anchor proof does not verify: ${v.reason ?? "unspecified failure"}`,
395
+ });
396
+ }
397
+ else {
398
+ lines.push({
399
+ name: "signature",
400
+ result: "TRUE",
401
+ detail: `anchor proof verifies under signer key ${shortB64(proof.publicKeyB64)}`,
402
+ });
403
+ }
404
+ // witness: present and verified is TRUE; present and failing is FALSE;
405
+ // absent is not a line (absence is not a verdict) and shows in bounds.
406
+ let witnessPath;
407
+ if (outcomes.length > 0) {
408
+ const verified = outcomes.find((o) => o.verified);
409
+ if (verified !== undefined) {
410
+ witnessPath = verified.witnessPath;
411
+ lines.push({
412
+ name: "witness",
413
+ result: "TRUE",
414
+ detail: `block header ${verified.witnessPath} hashes (keccak-256) to the anchored block hash${anchor.blockNumber !== undefined ? ` of block ${anchor.blockNumber}` : ""}`,
415
+ });
416
+ }
417
+ else {
418
+ const first = outcomes[0];
419
+ witnessPath = first.witnessPath;
420
+ lines.push({
421
+ name: "witness",
422
+ result: "FALSE",
423
+ detail: `block header ${first.witnessPath} contradicts this anchor: ${first.detail ?? first.reason ?? "witness verification failed"}`,
424
+ });
425
+ }
426
+ }
427
+ return {
428
+ proofHash: proof.proofHash,
429
+ ...(proof.epochId !== undefined ? { epochId: proof.epochId } : {}),
430
+ chainId: proof.chainId,
431
+ ...(proof.counter !== undefined ? { counter: proof.counter } : {}),
432
+ ...(anchor.blockNumber !== undefined ? { blockNumber: anchor.blockNumber } : {}),
433
+ ...(anchor.blockHash !== undefined ? { blockHash: anchor.blockHash } : {}),
434
+ ...(witnessPath !== undefined ? { witnessPath } : {}),
435
+ lines,
436
+ result: kleeneAll(lines.map((l) => l.result)),
437
+ };
438
+ }
439
+ // ---------------------------------------------------------------------------
440
+ // Contradictions, notes, not-checked
441
+ // ---------------------------------------------------------------------------
442
+ function collectContradictions(audit) {
443
+ const out = [];
444
+ const seen = new Set();
445
+ const push = (detail) => {
446
+ if (seen.has(detail))
447
+ return;
448
+ seen.add(detail);
449
+ out.push({ name: "contradiction", result: "FALSE", detail });
450
+ };
451
+ for (const anomaly of audit.anomalies.anomalies) {
452
+ if (EXCERPT_NORMAL_CODES.has(anomaly.code))
453
+ continue;
454
+ push(`${anomaly.code}: ${anomaly.message}`);
455
+ }
456
+ for (const divergence of audit.anomalies.divergences) {
457
+ push(`${divergence.kind}: ${divergence.explanation}`);
458
+ }
459
+ for (const anomaly of audit.authorities.anomalies) {
460
+ push(`${anomaly.code}: ${anomaly.message}`);
461
+ }
462
+ // Witness files that matched no anchor, or that are malformed, are
463
+ // supplied evidence that fails; witness outcomes bound to an anchor are
464
+ // already on that anchor's line.
465
+ for (const finding of audit.witnesses.findings) {
466
+ if (finding.code === "witness-unmatched" || finding.code === "witness-malformed" || finding.code === "witness-rlp-invalid") {
467
+ push(`${finding.code}: ${finding.message}`);
468
+ }
469
+ }
470
+ // An embedded proofHash that does not match the recomputed one: the
471
+ // stored proof file was altered after the ledger wrote it, or is not
472
+ // what it claims.
473
+ for (const finding of audit.ingest.findings) {
474
+ if (finding.code === "proofhash-mismatch") {
475
+ push(`${finding.code}: ${finding.message}${finding.path !== undefined ? ` (${finding.path})` : ""}`);
476
+ }
477
+ }
478
+ return out;
479
+ }
480
+ function collectNotes(audit, recordings, anchors) {
481
+ const notes = [];
482
+ // Excerpt gaps, said once, plainly.
483
+ let missingPositions = 0n;
484
+ let predecessorsAbsent = 0;
485
+ for (const anomaly of audit.anomalies.anomalies) {
486
+ if (anomaly.code === "unexplained-counter-positions") {
487
+ const count = anomaly.details?.count;
488
+ if (count !== undefined && /^[0-9]+$/.test(count))
489
+ missingPositions += BigInt(count);
490
+ }
491
+ else if (anomaly.code === "chain-break-missing") {
492
+ predecessorsAbsent += anomaly.proofHashes.length;
493
+ }
494
+ }
495
+ if (missingPositions > 0n) {
496
+ notes.push(`${missingPositions} causal position${missingPositions === 1n ? "" : "s"} between the earliest and latest recording here ${missingPositions === 1n ? "is" : "are"} not in this bundle: normal for an export, which is an excerpt of the chain; a full-epoch audit checks them`);
497
+ }
498
+ if (predecessorsAbsent > 0) {
499
+ notes.push(`${predecessorsAbsent} recording${predecessorsAbsent === 1 ? "" : "s"} link${predecessorsAbsent === 1 ? "s" : ""} to a predecessor that is not in this bundle: normal for an export`);
500
+ }
501
+ // Files that match no recording: the human hint for an edited file or a
502
+ // wrong drop, without claiming which.
503
+ const unmatched = audit.ingest.artifacts.filter((a) => a.matchedProofHashes.length === 0);
504
+ if (unmatched.length > 0 && recordings.length > 0) {
505
+ const listed = unmatched
506
+ .slice(0, 5)
507
+ .map((a) => `${a.paths[0] ?? "(unnamed)"} (sha256:${a.sha256Hex.slice(0, 16)}…)`)
508
+ .join(", ");
509
+ notes.push(`${unmatched.length} file${unmatched.length === 1 ? "" : "s"} in this bundle match${unmatched.length === 1 ? "es" : ""} no recording here: ${listed}${unmatched.length > 5 ? ", …" : ""}. If ${unmatched.length === 1 ? "it" : "one of them"} was meant to be a recorded file, its bytes differ from what was recorded`);
510
+ }
511
+ // Anchors with no witness: their bounds are not established here.
512
+ const anchorsWithoutWitness = anchors.filter((a) => a.witnessPath === undefined);
513
+ if (anchorsWithoutWitness.length > 0) {
514
+ notes.push(`${anchorsWithoutWitness.length} Ethereum anchor${anchorsWithoutWitness.length === 1 ? "" : "s"} in this bundle ha${anchorsWithoutWitness.length === 1 ? "s" : "ve"} no block-header witness file, so ${anchorsWithoutWitness.length === 1 ? "its" : "their"} block hash${anchorsWithoutWitness.length === 1 ? " is" : "es are"} not verified here and no bound is derived from ${anchorsWithoutWitness.length === 1 ? "it" : "them"}`);
515
+ }
516
+ for (const rec of audit.ingest.unsupportedVersions) {
517
+ notes.push(`${rec.path} is proof-shaped but its version "${rec.version}" is not bitgraph/1; it was not checked`);
518
+ }
519
+ const c = audit.ingest.counts;
520
+ if (c.exactDuplicates > 0 || c.semanticDuplicates > 0) {
521
+ notes.push(`${c.exactDuplicates + c.semanticDuplicates} duplicate proof file${c.exactDuplicates + c.semanticDuplicates === 1 ? "" : "s"} collapsed to one recording each`);
522
+ }
523
+ if (audit.ingest.manifest !== undefined) {
524
+ for (const finding of audit.ingest.findings) {
525
+ if (typeof finding.code === "string" && finding.code.startsWith("manifest-")) {
526
+ notes.push(`${finding.code}: ${finding.message}`);
527
+ }
528
+ }
529
+ }
530
+ if (recordings.length === 0 && anchors.length === 0) {
531
+ notes.push("no BitGraph proofs were found in this bundle");
532
+ }
533
+ return notes;
534
+ }
535
+ function collectNotChecked(hasAnchors) {
536
+ const out = [];
537
+ if (hasAnchors) {
538
+ out.push("whether the anchored Ethereum blocks are canonical: their headers are recomputed here, but canonicality needs an Ethereum node or a block explorer");
539
+ }
540
+ out.push("whether the public ledger holds these exact recordings at these positions: this is an offline check; drop the file on bitgraph.ing to compare against the ledger");
541
+ out.push("whether an epoch key was later quarantined: that is published outside any bundle");
542
+ return out;
543
+ }
544
+ // ---------------------------------------------------------------------------
545
+ // Summary sentence
546
+ // ---------------------------------------------------------------------------
547
+ function summarize(result, recordings, anchors, contradictions) {
548
+ if (recordings.length === 0 && anchors.length === 0) {
549
+ return "UNDETERMINED: no BitGraph proofs were found in this bundle.";
550
+ }
551
+ const n = recordings.length;
552
+ const noun = `${n} recording${n === 1 ? "" : "s"}`;
553
+ if (result === "TRUE") {
554
+ const first = recordings[0];
555
+ if (n === 1 && first !== undefined) {
556
+ const where = first.counter !== undefined ? ` at position ${first.counter}${first.epochId !== undefined ? ` of epoch ${shortB64(first.epochId)}` : ""}` : "";
557
+ return `TRUE: this file was recorded${where}, ${shortBoundsPhrase(first.bounds)}.`;
558
+ }
559
+ return `TRUE: ${noun} verified: files match, signatures and attestations hold, enclave measurements published${anchors.length > 0 ? ", Ethereum bounds verified from block headers in the bundle" : ""}.`;
560
+ }
561
+ if (result === "FALSE") {
562
+ const failing = [
563
+ ...recordings.flatMap((r) => r.lines.filter((l) => l.result === "FALSE").map((l) => `${l.name}: ${l.detail}`)),
564
+ ...anchors.flatMap((a) => a.lines.filter((l) => l.result === "FALSE").map((l) => `${l.name}: ${l.detail}`)),
565
+ ...contradictions.map((c) => c.detail),
566
+ ];
567
+ return `FALSE: evidence in this bundle contradicts itself. ${failing[0] ?? ""}`.trim();
568
+ }
569
+ const open = [
570
+ ...recordings.flatMap((r) => r.lines.filter((l) => l.result === "UNDETERMINED").map((l) => l.detail)),
571
+ ...anchors.flatMap((a) => a.lines.filter((l) => l.result === "UNDETERMINED").map((l) => l.detail)),
572
+ ];
573
+ return `UNDETERMINED: nothing here contradicts the ${noun}, but the evidence does not fully decide. ${open[0] ?? ""}`.trim();
574
+ }
575
+ // ---------------------------------------------------------------------------
576
+ // Text rendering (the CLI's stdout; the browser page renders the same
577
+ // report shape into DOM, reusing every detail string verbatim)
578
+ // ---------------------------------------------------------------------------
579
+ export function renderCheckText(report) {
580
+ const out = [];
581
+ const mark = (r) => (r === "TRUE" ? "TRUE " : r === "FALSE" ? "FALSE" : "UNDET");
582
+ out.push(report.summary);
583
+ out.push("");
584
+ report.recordings.forEach((rec, i) => {
585
+ out.push(`Recording ${i + 1}${rec.filePath !== undefined ? `: ${rec.filePath}` : ""} [${rec.result}]`);
586
+ out.push(` digest ${rec.digestB64}`);
587
+ if (rec.counter !== undefined) {
588
+ out.push(` position ${rec.counter}${rec.epochId !== undefined ? ` epoch ${rec.epochId}` : ""}`);
589
+ }
590
+ for (const line of rec.lines)
591
+ out.push(` ${mark(line.result)} ${pad(line.name, 12)} ${line.detail}`);
592
+ out.push(` bounds ${rec.bounds.detail}`);
593
+ out.push("");
594
+ });
595
+ report.anchors.forEach((a, i) => {
596
+ out.push(`Ethereum anchor ${i + 1}${a.blockNumber !== undefined ? `: block ${a.blockNumber}` : ""}${a.counter !== undefined ? ` at position ${a.counter}` : ""} [${a.result}]`);
597
+ for (const line of a.lines)
598
+ out.push(` ${mark(line.result)} ${pad(line.name, 12)} ${line.detail}`);
599
+ out.push("");
600
+ });
601
+ if (report.contradictions.length > 0) {
602
+ out.push("Contradictions");
603
+ for (const c of report.contradictions)
604
+ out.push(` FALSE ${c.detail}`);
605
+ out.push("");
606
+ }
607
+ if (report.notes.length > 0) {
608
+ out.push("Notes");
609
+ for (const n of report.notes)
610
+ out.push(` - ${n}`);
611
+ out.push("");
612
+ }
613
+ out.push("Not checked (no offline check can establish these)");
614
+ for (const n of report.notChecked)
615
+ out.push(` - ${n}`);
616
+ out.push("");
617
+ out.push(`Result: ${report.result} (bitgraph-player ${report.evaluator.version}, bitgraph-audit ${report.evaluator.audit}, network: none)`);
618
+ return out.join("\n") + "\n";
619
+ }
620
+ /** Deterministic JSON bytes for the report: two-space indent, one trailing newline. */
621
+ export function serializeCheckReport(report) {
622
+ return JSON.stringify(report, null, 2) + "\n";
623
+ }
624
+ // ---------------------------------------------------------------------------
625
+ // Small helpers
626
+ // ---------------------------------------------------------------------------
627
+ function shortB64(s) {
628
+ if (s === undefined)
629
+ return "(none)";
630
+ return s.length > 12 ? `${s.slice(0, 12)}…` : s;
631
+ }
632
+ function shortHex(s) {
633
+ if (s === undefined)
634
+ return "(none)";
635
+ return s.length > 16 ? `${s.slice(0, 16)}…` : s;
636
+ }
637
+ function pad(s, n) {
638
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
639
+ }
640
+ //# sourceMappingURL=check.js.map