@mikeargento/bitgraph-player 0.3.0 → 0.5.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/src/check.ts ADDED
@@ -0,0 +1,873 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * `check`: what a bundle establishes about the recordings it holds, said
5
+ * plainly, offline, in the Player's three values.
6
+ *
7
+ * This is a SPEC section 8 convenience: a distinct subcommand that never
8
+ * touches evaluation semantics. It asks no rule-author question ("was A
9
+ * before B"); it asks the reader's question ("is this recording sound, and
10
+ * what bounds it"), over the audit pipeline's canonical interpretation of
11
+ * the bundle, and answers per line with TRUE, FALSE, or UNDETERMINED.
12
+ *
13
+ * The vocabulary, and the line it lives or dies on:
14
+ *
15
+ * TRUE evidence in hand establishes the property.
16
+ * FALSE evidence in hand CONTRADICTS the property: bytes that do
17
+ * not hash to the digest they sit beside, a signature that
18
+ * fails, an attestation whose user_data is not this proof, a
19
+ * block header that does not hash to its anchor's block hash,
20
+ * two recordings that cannot both be right.
21
+ * UNDETERMINED the evidence does not decide. Missing bytes, a missing
22
+ * witness, an environment that cannot run a check, a
23
+ * measurement this verifier does not know. Absence is never
24
+ * a verdict, and this command never degrades a failed or
25
+ * missing check into a FALSE (project rule: a failed read is
26
+ * never a verdict).
27
+ *
28
+ * The overall result is the strong-Kleene `all` over every line the report
29
+ * emits: each recording's lines, each anchor's lines, and each structural
30
+ * contradiction. Ethereum BOUNDS are reported descriptively per recording
31
+ * and never enter the conjunction: a bundle without an anchor has an
32
+ * unbounded recording, not an unsound one. A witness that is PRESENT and
33
+ * fails is a contradiction and does enter.
34
+ *
35
+ * Excerpt honesty. A single export is an excerpt of a chain: its recording
36
+ * links to a predecessor that is not in the bundle, and the counter
37
+ * positions between its anchors are absent. The audit reports those as
38
+ * anomalies because it is built to audit whole epochs; here they are
39
+ * expected and are reported as notes, never as verdicts. Real
40
+ * contradictions (collisions, forks, malformed links, signer changes)
41
+ * still surface as FALSE.
42
+ *
43
+ * Enclave identity. An attestation proves that SOME AWS Nitro enclave
44
+ * running code with the attested PCR0 signed this proof's key. Whether
45
+ * that PCR0 is a published BitGraph enclave measurement is a fact this
46
+ * verifier carries as DECLARED knowledge (KNOWN_ENCLAVE_MEASUREMENTS, from
47
+ * server/commit-service/reproducible-build/PINS.md). A measurement outside
48
+ * that list is UNDETERMINED, never FALSE: it is beyond what this build of
49
+ * the verifier knows, which is exactly how an offline verifier should age.
50
+ *
51
+ * Determinism. The report contains no wall-clock time, no machine path, and
52
+ * no run-local value; bundle-relative entry paths are bundle content and
53
+ * are allowed. Same bundle bytes, same report bytes.
54
+ */
55
+
56
+ import type {
57
+ AnchorRecord,
58
+ AuditResult,
59
+ ChainAnomaly,
60
+ IngestResult,
61
+ ObservedProof,
62
+ ProofAttestationRecord,
63
+ SegmentBound,
64
+ TemporalSegment,
65
+ } from "@mikeargento/bitgraph-audit";
66
+ import { auditIngest, AUDIT_VERSION } from "@mikeargento/bitgraph-audit";
67
+ import type { ThreeValued } from "./types.js";
68
+ import { kleeneAll } from "./logic.js";
69
+ import { PLAYER_VERSION } from "./verdict.js";
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Declared knowledge: published enclave measurements
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Published BitGraph enclave PCR0 measurements, per PINS.md, oldest first.
77
+ * Each remains correct for proofs minted during its own period; a proof
78
+ * carries its own measurement, so nothing here needs to be "current" for
79
+ * an old proof to check. Lowercase hex, exactly as attestations report.
80
+ */
81
+ export const KNOWN_ENCLAVE_MEASUREMENTS: ReadonlyArray<{ pcr0: string; label: string; period: string }> = [
82
+ {
83
+ pcr0: "8530a6399399c4f23d89f5a1faa2e8bf2e09a5959f117070fca08148377f92c902c695fc926c17f67f35f110327dca92",
84
+ label: "genesis",
85
+ period: "2026-05-15 to 2026-06-27",
86
+ },
87
+ {
88
+ pcr0: "bb9dd158703603ec222fe565495ceaa7edc08f665da5c1cddad91442ac2211731390267036d79deb720d13fb704f648a",
89
+ label: "enclave v2 (reproducible)",
90
+ period: "2026-06-27 to 2026-07-05",
91
+ },
92
+ {
93
+ pcr0: "e2fccbae77ee40aac4830e84f195e05d69eb4547bbd961f4d3459feba10807140424aca42ad03810354982598c86b9cb",
94
+ label: "enclave v4 (reproducible)",
95
+ period: "2026-07-05 to 2026-07-29",
96
+ },
97
+ {
98
+ pcr0: "6483cedffed74680ffb287507744a398b288c3fb943eb3f2e4fe889f8b60b3d575ad8942350360b69a1bd7bf713df27f",
99
+ label: "enclave v5 (reproducible)",
100
+ period: "2026-07-29 onward",
101
+ },
102
+ ];
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Report types: bitgraph-check/1
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /** One checked property, three-valued, with a plain-language reason. */
109
+ export interface CheckLine {
110
+ name: "file" | "signature" | "attestation" | "enclave" | "witness" | "contradiction";
111
+ result: ThreeValued;
112
+ detail: string;
113
+ }
114
+
115
+ /** A verified Ethereum bound on a recording, from a witness in the bundle. */
116
+ export interface CheckBound {
117
+ blockNumber?: string;
118
+ blockHash: string;
119
+ /** Unix seconds from the verified block header. */
120
+ timestamp: number;
121
+ anchorProofHash: string;
122
+ evidence: "chain-link" | "counter-order";
123
+ weaker: boolean;
124
+ }
125
+
126
+ export interface CheckBounds {
127
+ status: "bracketed" | "lower-bounded" | "upper-bounded" | "unanchored";
128
+ notBefore?: CheckBound;
129
+ notAfter?: CheckBound;
130
+ detail: string;
131
+ }
132
+
133
+ /** A non-anchor recording in the bundle. */
134
+ export interface CheckRecording {
135
+ proofHash: string;
136
+ digestB64: string;
137
+ epochId?: string;
138
+ chainId: string;
139
+ counter?: string;
140
+ slotCounter?: string;
141
+ publicKeyB64?: string;
142
+ /** Bundle-relative path of the matched artifact, when its bytes were present. */
143
+ filePath?: string;
144
+ lines: CheckLine[];
145
+ /** Kleene all over `lines`. */
146
+ result: ThreeValued;
147
+ bounds: CheckBounds;
148
+ }
149
+
150
+ /** An Ethereum anchor recording in the bundle. */
151
+ export interface CheckAnchor {
152
+ proofHash: string;
153
+ epochId?: string;
154
+ chainId: string;
155
+ counter?: string;
156
+ blockNumber?: string;
157
+ blockHash?: string;
158
+ /** Bundle-relative path of the witness file, when one matched this anchor. */
159
+ witnessPath?: string;
160
+ lines: CheckLine[];
161
+ result: ThreeValued;
162
+ }
163
+
164
+ export interface CheckReport {
165
+ check: "bitgraph-check/1";
166
+ result: ThreeValued;
167
+ /** One-sentence plain-language conclusion, deterministic. */
168
+ summary: string;
169
+ recordings: CheckRecording[];
170
+ anchors: CheckAnchor[];
171
+ /** Structural findings the bundle contradicts itself on. Every entry is FALSE. */
172
+ contradictions: CheckLine[];
173
+ /** Informational: excerpt gaps, unmatched files, duplicates. Never verdicts. */
174
+ notes: string[];
175
+ /** What no offline check can establish, stated rather than implied. */
176
+ notChecked: string[];
177
+ evaluator: { name: "bitgraph-player"; version: string; audit: string };
178
+ network: "none";
179
+ }
180
+
181
+ export interface CheckOptions {
182
+ /**
183
+ * False when the environment cannot run the attestation's ECDSA P-384
184
+ * verification (no WebCrypto). Attestation lines are then UNDETERMINED
185
+ * with that reason instead of a false FALSE. Defaults to true.
186
+ */
187
+ webCryptoAvailable?: boolean;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Anomaly classification for excerpts
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /**
195
+ * Codes that mean "the bundle is an excerpt", not "the chain is wrong":
196
+ * a recording whose predecessor is not supplied, positions between the
197
+ * supplied recordings that are not supplied, an epoch link whose other end
198
+ * is not supplied. Reported as notes. Everything else classifyAnomalies
199
+ * emits is a contradiction among the supplied proofs and surfaces as FALSE.
200
+ */
201
+ const EXCERPT_NORMAL_CODES: ReadonlySet<string> = new Set([
202
+ "unexplained-counter-positions",
203
+ "chain-break-missing",
204
+ "epochlink-terminal-missing",
205
+ "epochlink-dangling",
206
+ ]);
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Public API
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /**
213
+ * Check an already-ingested bundle: runs the audit's pure tail (no
214
+ * filesystem, no network) and builds the report. Both the CLI and the
215
+ * browser page call this, so they cannot drift.
216
+ */
217
+ export async function checkIngest(ingest: IngestResult, options?: CheckOptions): Promise<CheckReport> {
218
+ const audit = await auditIngest(ingest, { startedAt: "" });
219
+ return buildCheckReport(audit, options);
220
+ }
221
+
222
+ /** The pure report builder over an AuditResult. */
223
+ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): CheckReport {
224
+ const webCrypto = options?.webCryptoAvailable ?? true;
225
+ const anchorByHash = new Map<string, AnchorRecord>(audit.anchors.anchors.map((a) => [a.proofHash, a]));
226
+ const attestationByHash = new Map<string, ProofAttestationRecord>(
227
+ audit.attestations.records.map((r) => [r.proofHash, r])
228
+ );
229
+ const segmentByHash = new Map<string, TemporalSegment>();
230
+ for (const segment of audit.temporal.segments) {
231
+ for (const hash of segment.memberProofHashes) segmentByHash.set(hash, segment);
232
+ }
233
+ const witnessOutcomesByAnchor = new Map<string, typeof audit.witnesses.outcomes>();
234
+ for (const outcome of audit.witnesses.outcomes) {
235
+ if (outcome.anchorProofHash === undefined) continue;
236
+ const list = witnessOutcomesByAnchor.get(outcome.anchorProofHash) ?? [];
237
+ list.push(outcome);
238
+ witnessOutcomesByAnchor.set(outcome.anchorProofHash, list);
239
+ }
240
+ const artifactPathByProof = new Map<string, string>();
241
+ for (const artifact of audit.ingest.artifacts) {
242
+ for (const hash of artifact.matchedProofHashes) {
243
+ if (!artifactPathByProof.has(hash) && artifact.paths[0] !== undefined) {
244
+ artifactPathByProof.set(hash, artifact.paths[0]);
245
+ }
246
+ }
247
+ }
248
+
249
+ const recordings: CheckRecording[] = [];
250
+ const anchors: CheckAnchor[] = [];
251
+
252
+ for (const proof of audit.ingest.proofs) {
253
+ const anchor = anchorByHash.get(proof.proofHash);
254
+ if (anchor !== undefined) {
255
+ anchors.push(buildAnchor(proof, anchor, witnessOutcomesByAnchor.get(proof.proofHash) ?? []));
256
+ } else {
257
+ recordings.push(
258
+ buildRecording(
259
+ proof,
260
+ attestationByHash.get(proof.proofHash),
261
+ segmentByHash.get(proof.proofHash),
262
+ artifactPathByProof.get(proof.proofHash),
263
+ webCrypto
264
+ )
265
+ );
266
+ }
267
+ }
268
+
269
+ sortByPosition(recordings);
270
+ sortByPosition(anchors);
271
+ const contradictions = collectContradictions(audit);
272
+ const notes = collectNotes(audit, recordings, anchors);
273
+ const notChecked = collectNotChecked(anchors.length > 0);
274
+
275
+ const allLines: ThreeValued[] = [
276
+ ...recordings.map((r) => r.result),
277
+ ...anchors.map((a) => a.result),
278
+ ...contradictions.map((c) => c.result),
279
+ ];
280
+ const result: ThreeValued = allLines.length === 0 ? "UNDETERMINED" : kleeneAll(allLines);
281
+
282
+ return {
283
+ check: "bitgraph-check/1",
284
+ result,
285
+ summary: summarize(result, recordings, anchors, contradictions),
286
+ recordings,
287
+ anchors,
288
+ contradictions,
289
+ notes,
290
+ notChecked,
291
+ evaluator: { name: "bitgraph-player", version: PLAYER_VERSION, audit: AUDIT_VERSION },
292
+ network: "none",
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Causal display order: by epoch id, then commit counter as an integer.
298
+ * Entries without a parseable counter keep observation order after those
299
+ * with one. Deterministic, and it puts the before-anchor before the
300
+ * after-anchor regardless of file names.
301
+ */
302
+ function sortByPosition<T extends { epochId?: string; counter?: string }>(items: T[]): void {
303
+ const key = (x: T): [string, bigint | undefined] => [
304
+ x.epochId ?? "",
305
+ x.counter !== undefined && /^[0-9]+$/.test(x.counter) ? BigInt(x.counter) : undefined,
306
+ ];
307
+ items.sort((a, b) => {
308
+ const [ae, ac] = key(a);
309
+ const [be, bc] = key(b);
310
+ if (ae !== be) return ae < be ? -1 : 1;
311
+ if (ac === undefined || bc === undefined) return ac === undefined ? (bc === undefined ? 0 : 1) : -1;
312
+ return ac < bc ? -1 : ac > bc ? 1 : 0;
313
+ });
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Recordings
318
+ // ---------------------------------------------------------------------------
319
+
320
+ function buildRecording(
321
+ proof: ObservedProof,
322
+ attestation: ProofAttestationRecord | undefined,
323
+ segment: TemporalSegment | undefined,
324
+ filePath: string | undefined,
325
+ webCrypto: boolean
326
+ ): CheckRecording {
327
+ const lines: CheckLine[] = [];
328
+ const v = proof.verification;
329
+ const digestB64 = proof.proof.artifact.digestB64;
330
+
331
+ // file: the bytes in hand hash to the recorded digest.
332
+ if (v?.tier === "full") {
333
+ lines.push({
334
+ name: "file",
335
+ result: v.status === "verified" || v.status === "failed" ? "TRUE" : "UNDETERMINED",
336
+ detail:
337
+ v.status === "verified" || v.status === "failed"
338
+ ? `${filePath ?? "the matched file"} hashes to the recorded digest ${digestB64}`
339
+ : `no file in this bundle hashes to the recorded digest ${digestB64}`,
340
+ });
341
+ } else {
342
+ lines.push({
343
+ name: "file",
344
+ result: "UNDETERMINED",
345
+ detail: `no file in this bundle hashes to the recorded digest ${digestB64}; the recording cannot be bound to bytes in hand`,
346
+ });
347
+ }
348
+
349
+ // signature: the proof body verifies (structure, slot binding, Ed25519).
350
+ // At full tier a matched artifact always hashes to the digest (matching
351
+ // is content-addressed), so a full-tier failure is a signature or
352
+ // structure failure, never a digest mismatch.
353
+ if (v === undefined) {
354
+ lines.push({ name: "signature", result: "UNDETERMINED", detail: "the proof was not verified" });
355
+ } else if (v.status === "failed") {
356
+ lines.push({
357
+ name: "signature",
358
+ result: "FALSE",
359
+ detail: `the proof does not verify: ${v.reason ?? "unspecified failure"}`,
360
+ });
361
+ } else {
362
+ lines.push({
363
+ name: "signature",
364
+ result: "TRUE",
365
+ detail: `Ed25519 signature and slot binding verify under signer key ${shortB64(proof.publicKeyB64)}`,
366
+ });
367
+ }
368
+
369
+ // attestation: the AWS Nitro document validates, is bound to THIS proof
370
+ // (user_data), and attests the PCR0 the signed body declares.
371
+ const att = attestationLine(attestation, webCrypto);
372
+ lines.push(att.line);
373
+
374
+ // enclave: the attested PCR0 is a published BitGraph measurement.
375
+ lines.push(enclaveLine(att.attestedPcr0, att.line.result));
376
+
377
+ const result = kleeneAll(lines.map((l) => l.result));
378
+
379
+ return {
380
+ proofHash: proof.proofHash,
381
+ digestB64,
382
+ ...(proof.epochId !== undefined ? { epochId: proof.epochId } : {}),
383
+ chainId: proof.chainId,
384
+ ...(proof.counter !== undefined ? { counter: proof.counter } : {}),
385
+ ...(proof.slotCounter !== undefined ? { slotCounter: proof.slotCounter } : {}),
386
+ ...(proof.publicKeyB64 !== undefined ? { publicKeyB64: proof.publicKeyB64 } : {}),
387
+ ...(filePath !== undefined && v?.tier === "full" ? { filePath } : {}),
388
+ lines,
389
+ result,
390
+ bounds: boundsFor(segment),
391
+ };
392
+ }
393
+
394
+ function attestationLine(
395
+ record: ProofAttestationRecord | undefined,
396
+ webCrypto: boolean
397
+ ): { line: CheckLine; attestedPcr0?: string | undefined } {
398
+ if (record === undefined || !record.documentPresent) {
399
+ return {
400
+ line: {
401
+ name: "attestation",
402
+ result: "UNDETERMINED",
403
+ detail: "no attestation document in this proof",
404
+ },
405
+ };
406
+ }
407
+ if (!webCrypto) {
408
+ return {
409
+ line: {
410
+ name: "attestation",
411
+ result: "UNDETERMINED",
412
+ detail:
413
+ "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",
414
+ },
415
+ };
416
+ }
417
+ if (!record.documentValidated) {
418
+ return {
419
+ line: {
420
+ name: "attestation",
421
+ result: "FALSE",
422
+ detail: `attestation document does not validate: ${record.validationFailure ?? "unspecified failure"}`,
423
+ },
424
+ };
425
+ }
426
+ if (record.userDataBoundToProof !== true) {
427
+ return {
428
+ line: {
429
+ name: "attestation",
430
+ result: "FALSE",
431
+ detail: "attestation document validates but its user_data is not this proof: the document belongs to some other proof",
432
+ },
433
+ attestedPcr0: record.attestedPcr0,
434
+ };
435
+ }
436
+ if (record.pcr0MatchesDeclared !== true) {
437
+ return {
438
+ line: {
439
+ name: "attestation",
440
+ result: "FALSE",
441
+ detail: `attestation validates and binds this proof, but its PCR0 ${shortHex(record.attestedPcr0)} is not the measurement the signed body declares ${shortHex(record.declaredMeasurement)}`,
442
+ },
443
+ attestedPcr0: record.attestedPcr0,
444
+ };
445
+ }
446
+ return {
447
+ line: {
448
+ name: "attestation",
449
+ result: "TRUE",
450
+ detail: `AWS Nitro attestation validates to the AWS root, binds this exact proof (user_data), and attests PCR0 ${shortHex(record.attestedPcr0)}`,
451
+ },
452
+ attestedPcr0: record.attestedPcr0,
453
+ };
454
+ }
455
+
456
+ function enclaveLine(attestedPcr0: string | undefined, attestationResult: ThreeValued): CheckLine {
457
+ if (attestationResult !== "TRUE" || attestedPcr0 === undefined) {
458
+ return {
459
+ name: "enclave",
460
+ result: "UNDETERMINED",
461
+ detail: "enclave identity rests on a validated attestation, which this recording does not have here",
462
+ };
463
+ }
464
+ const known = KNOWN_ENCLAVE_MEASUREMENTS.find((m) => m.pcr0 === attestedPcr0.toLowerCase());
465
+ if (known === undefined) {
466
+ return {
467
+ name: "enclave",
468
+ result: "UNDETERMINED",
469
+ 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`,
470
+ };
471
+ }
472
+ return {
473
+ name: "enclave",
474
+ result: "TRUE",
475
+ detail: `PCR0 ${shortHex(attestedPcr0)} is the published BitGraph ${known.label} measurement (${known.period})`,
476
+ };
477
+ }
478
+
479
+ function boundsFor(segment: TemporalSegment | undefined): CheckBounds {
480
+ if (segment === undefined) {
481
+ return { status: "unanchored", detail: "no verified Ethereum anchor bounds this recording in this bundle" };
482
+ }
483
+ const lower = tightest(segment.lowerBounds, "not-before");
484
+ const upper = tightest(segment.upperBounds, "not-after");
485
+ const notBefore = lower === undefined ? undefined : toBound(lower);
486
+ const notAfter = upper === undefined ? undefined : toBound(upper);
487
+ const status: CheckBounds["status"] =
488
+ notBefore !== undefined && notAfter !== undefined
489
+ ? "bracketed"
490
+ : notBefore !== undefined
491
+ ? "lower-bounded"
492
+ : notAfter !== undefined
493
+ ? "upper-bounded"
494
+ : "unanchored";
495
+ const detail =
496
+ status === "unanchored"
497
+ ? "no verified Ethereum anchor bounds this recording in this bundle"
498
+ : `recorded ${boundsPhrase(status, notBefore, notAfter)}`;
499
+ return {
500
+ status,
501
+ ...(notBefore !== undefined ? { notBefore } : {}),
502
+ ...(notAfter !== undefined ? { notAfter } : {}),
503
+ detail,
504
+ };
505
+ }
506
+
507
+ /** "after Ethereum block A and before block B (both headers verified)…", shared by bounds.detail and the summary. */
508
+ function boundsPhrase(
509
+ status: CheckBounds["status"],
510
+ notBefore: CheckBound | undefined,
511
+ notAfter: CheckBound | undefined
512
+ ): string {
513
+ switch (status) {
514
+ case "bracketed":
515
+ return (
516
+ `after Ethereum block ${blockRef(notBefore as CheckBound)} and before block ${blockRef(notAfter as CheckBound)} (both headers verified in this bundle)` +
517
+ weakerSuffix((notBefore as CheckBound).weaker || (notAfter as CheckBound).weaker)
518
+ );
519
+ case "lower-bounded":
520
+ return `after Ethereum block ${blockRef(notBefore as CheckBound)} (header verified in this bundle); no verified upper bound here` + weakerSuffix((notBefore as CheckBound).weaker);
521
+ case "upper-bounded":
522
+ return `before Ethereum block ${blockRef(notAfter as CheckBound)} (header verified in this bundle); no verified lower bound here` + weakerSuffix((notAfter as CheckBound).weaker);
523
+ default:
524
+ return "with no verified Ethereum bound in this bundle";
525
+ }
526
+ }
527
+
528
+ /**
529
+ * The tightest bound of a kind: for not-before the LARGEST block number
530
+ * (latest verified anchor known to precede), for not-after the SMALLEST.
531
+ * Ties resolve by preferring chain-link evidence over counter-order.
532
+ */
533
+ function tightest(bounds: SegmentBound[], kind: "not-before" | "not-after"): SegmentBound | undefined {
534
+ const ofKind = bounds.filter((b) => b.kind === kind);
535
+ if (ofKind.length === 0) return undefined;
536
+ const sorted = [...ofKind].sort((a, b) => {
537
+ const an = a.blockNumber !== undefined ? BigInt(a.blockNumber) : BigInt(a.timestamp);
538
+ const bn = b.blockNumber !== undefined ? BigInt(b.blockNumber) : BigInt(b.timestamp);
539
+ if (an !== bn) return kind === "not-before" ? (an > bn ? -1 : 1) : an < bn ? -1 : 1;
540
+ if (a.weaker !== b.weaker) return a.weaker ? 1 : -1;
541
+ return 0;
542
+ });
543
+ return sorted[0];
544
+ }
545
+
546
+ function toBound(b: SegmentBound): CheckBound {
547
+ return {
548
+ ...(b.blockNumber !== undefined ? { blockNumber: b.blockNumber } : {}),
549
+ blockHash: b.blockHash,
550
+ timestamp: b.timestamp,
551
+ anchorProofHash: b.anchorProofHash,
552
+ evidence: b.evidence,
553
+ weaker: b.weaker,
554
+ };
555
+ }
556
+
557
+ /** The headline form of the bounds: one clause, no qualifiers (those live in bounds.detail). */
558
+ function shortBoundsPhrase(b: CheckBounds): string {
559
+ switch (b.status) {
560
+ case "bracketed":
561
+ return `between Ethereum blocks ${blockRef(b.notBefore as CheckBound)} and ${blockRef(b.notAfter as CheckBound)}`;
562
+ case "lower-bounded":
563
+ return `after Ethereum block ${blockRef(b.notBefore as CheckBound)}`;
564
+ case "upper-bounded":
565
+ return `before Ethereum block ${blockRef(b.notAfter as CheckBound)}`;
566
+ default:
567
+ return "with no Ethereum bound in this bundle";
568
+ }
569
+ }
570
+
571
+ function blockRef(b: CheckBound): string {
572
+ return b.blockNumber !== undefined ? b.blockNumber : b.blockHash;
573
+ }
574
+
575
+ function weakerSuffix(weaker: boolean): string {
576
+ return weaker
577
+ ? "; ordered by counter position within the epoch, since the recordings between are not in this bundle"
578
+ : "";
579
+ }
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // Anchors
583
+ // ---------------------------------------------------------------------------
584
+
585
+ function buildAnchor(
586
+ proof: ObservedProof,
587
+ anchor: AnchorRecord,
588
+ outcomes: Array<{ witnessPath: string; verified: boolean; detail?: string; reason?: string; blockNumber?: string }>
589
+ ): CheckAnchor {
590
+ const lines: CheckLine[] = [];
591
+ const v = proof.verification;
592
+ if (v === undefined) {
593
+ lines.push({ name: "signature", result: "UNDETERMINED", detail: "the anchor proof was not verified" });
594
+ } else if (v.status === "failed") {
595
+ lines.push({
596
+ name: "signature",
597
+ result: "FALSE",
598
+ detail: `the anchor proof does not verify: ${v.reason ?? "unspecified failure"}`,
599
+ });
600
+ } else {
601
+ lines.push({
602
+ name: "signature",
603
+ result: "TRUE",
604
+ detail: `anchor proof verifies under signer key ${shortB64(proof.publicKeyB64)}`,
605
+ });
606
+ }
607
+
608
+ // witness: present and verified is TRUE; present and failing is FALSE;
609
+ // absent is not a line (absence is not a verdict) and shows in bounds.
610
+ let witnessPath: string | undefined;
611
+ if (outcomes.length > 0) {
612
+ const verified = outcomes.find((o) => o.verified);
613
+ if (verified !== undefined) {
614
+ witnessPath = verified.witnessPath;
615
+ lines.push({
616
+ name: "witness",
617
+ result: "TRUE",
618
+ detail: `block header ${verified.witnessPath} hashes (keccak-256) to the anchored block hash${anchor.blockNumber !== undefined ? ` of block ${anchor.blockNumber}` : ""}`,
619
+ });
620
+ } else {
621
+ const first = outcomes[0] as { witnessPath: string; detail?: string; reason?: string };
622
+ witnessPath = first.witnessPath;
623
+ lines.push({
624
+ name: "witness",
625
+ result: "FALSE",
626
+ detail: `block header ${first.witnessPath} contradicts this anchor: ${first.detail ?? first.reason ?? "witness verification failed"}`,
627
+ });
628
+ }
629
+ }
630
+
631
+ return {
632
+ proofHash: proof.proofHash,
633
+ ...(proof.epochId !== undefined ? { epochId: proof.epochId } : {}),
634
+ chainId: proof.chainId,
635
+ ...(proof.counter !== undefined ? { counter: proof.counter } : {}),
636
+ ...(anchor.blockNumber !== undefined ? { blockNumber: anchor.blockNumber } : {}),
637
+ ...(anchor.blockHash !== undefined ? { blockHash: anchor.blockHash } : {}),
638
+ ...(witnessPath !== undefined ? { witnessPath } : {}),
639
+ lines,
640
+ result: kleeneAll(lines.map((l) => l.result)),
641
+ };
642
+ }
643
+
644
+ // ---------------------------------------------------------------------------
645
+ // Contradictions, notes, not-checked
646
+ // ---------------------------------------------------------------------------
647
+
648
+ function collectContradictions(audit: AuditResult): CheckLine[] {
649
+ const out: CheckLine[] = [];
650
+ const seen = new Set<string>();
651
+ const push = (detail: string): void => {
652
+ if (seen.has(detail)) return;
653
+ seen.add(detail);
654
+ out.push({ name: "contradiction", result: "FALSE", detail });
655
+ };
656
+
657
+ for (const anomaly of audit.anomalies.anomalies as ChainAnomaly[]) {
658
+ if (EXCERPT_NORMAL_CODES.has(anomaly.code)) continue;
659
+ push(`${anomaly.code}: ${anomaly.message}`);
660
+ }
661
+ for (const divergence of audit.anomalies.divergences) {
662
+ push(`${divergence.kind}: ${divergence.explanation}`);
663
+ }
664
+ for (const anomaly of audit.authorities.anomalies) {
665
+ push(`${anomaly.code}: ${anomaly.message}`);
666
+ }
667
+ // Witness files that matched no anchor, or that are malformed, are
668
+ // supplied evidence that fails; witness outcomes bound to an anchor are
669
+ // already on that anchor's line.
670
+ for (const finding of audit.witnesses.findings) {
671
+ if (finding.code === "witness-unmatched" || finding.code === "witness-malformed" || finding.code === "witness-rlp-invalid") {
672
+ push(`${finding.code}: ${finding.message}`);
673
+ }
674
+ }
675
+ // An embedded proofHash that does not match the recomputed one: the
676
+ // stored proof file was altered after the ledger wrote it, or is not
677
+ // what it claims.
678
+ for (const finding of audit.ingest.findings) {
679
+ if (finding.code === "proofhash-mismatch") {
680
+ push(`${finding.code}: ${finding.message}${finding.path !== undefined ? ` (${finding.path})` : ""}`);
681
+ }
682
+ }
683
+ return out;
684
+ }
685
+
686
+ function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors: CheckAnchor[]): string[] {
687
+ const notes: string[] = [];
688
+
689
+ // Excerpt gaps, said once, plainly.
690
+ let missingPositions = 0n;
691
+ let predecessorsAbsent = 0;
692
+ for (const anomaly of audit.anomalies.anomalies as ChainAnomaly[]) {
693
+ if (anomaly.code === "unexplained-counter-positions") {
694
+ const count = (anomaly.details as { count?: string } | undefined)?.count;
695
+ if (count !== undefined && /^[0-9]+$/.test(count)) missingPositions += BigInt(count);
696
+ } else if (anomaly.code === "chain-break-missing") {
697
+ predecessorsAbsent += anomaly.proofHashes.length;
698
+ }
699
+ }
700
+ if (missingPositions > 0n) {
701
+ notes.push(
702
+ `${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`
703
+ );
704
+ }
705
+ if (predecessorsAbsent > 0) {
706
+ notes.push(
707
+ `${predecessorsAbsent} recording${predecessorsAbsent === 1 ? "" : "s"} link${predecessorsAbsent === 1 ? "s" : ""} to a predecessor that is not in this bundle: normal for an export`
708
+ );
709
+ }
710
+
711
+ // Files that match no recording: the human hint for an edited file or a
712
+ // wrong drop, without claiming which.
713
+ const unmatched = audit.ingest.artifacts.filter((a) => a.matchedProofHashes.length === 0);
714
+ if (unmatched.length > 0 && recordings.length > 0) {
715
+ const listed = unmatched
716
+ .slice(0, 5)
717
+ .map((a) => `${a.paths[0] ?? "(unnamed)"} (sha256:${a.sha256Hex.slice(0, 16)}…)`)
718
+ .join(", ");
719
+ notes.push(
720
+ `${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`
721
+ );
722
+ }
723
+
724
+ // Anchors with no witness: their bounds are not established here.
725
+ const anchorsWithoutWitness = anchors.filter((a) => a.witnessPath === undefined);
726
+ if (anchorsWithoutWitness.length > 0) {
727
+ notes.push(
728
+ `${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"}`
729
+ );
730
+ }
731
+
732
+ for (const rec of audit.ingest.unsupportedVersions) {
733
+ notes.push(`${rec.path} is proof-shaped but its version "${rec.version}" is not bitgraph/1; it was not checked`);
734
+ }
735
+ const c = audit.ingest.counts;
736
+ if (c.exactDuplicates > 0 || c.semanticDuplicates > 0) {
737
+ notes.push(
738
+ `${c.exactDuplicates + c.semanticDuplicates} duplicate proof file${c.exactDuplicates + c.semanticDuplicates === 1 ? "" : "s"} collapsed to one recording each`
739
+ );
740
+ }
741
+ if (audit.ingest.manifest !== undefined) {
742
+ for (const finding of audit.ingest.findings) {
743
+ if (typeof finding.code === "string" && finding.code.startsWith("manifest-")) {
744
+ notes.push(`${finding.code}: ${finding.message}`);
745
+ }
746
+ }
747
+ }
748
+ if (recordings.length === 0 && anchors.length === 0) {
749
+ notes.push("no BitGraph proofs were found in this bundle");
750
+ }
751
+ return notes;
752
+ }
753
+
754
+ function collectNotChecked(hasAnchors: boolean): string[] {
755
+ const out: string[] = [];
756
+ if (hasAnchors) {
757
+ out.push(
758
+ "whether the anchored Ethereum blocks are canonical: their headers are recomputed here, but canonicality needs an Ethereum node or a block explorer"
759
+ );
760
+ }
761
+ out.push(
762
+ "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"
763
+ );
764
+ out.push("whether an epoch key was later quarantined: that is published outside any bundle");
765
+ return out;
766
+ }
767
+
768
+ // ---------------------------------------------------------------------------
769
+ // Summary sentence
770
+ // ---------------------------------------------------------------------------
771
+
772
+ function summarize(
773
+ result: ThreeValued,
774
+ recordings: CheckRecording[],
775
+ anchors: CheckAnchor[],
776
+ contradictions: CheckLine[]
777
+ ): string {
778
+ if (recordings.length === 0 && anchors.length === 0) {
779
+ return "UNDETERMINED: no BitGraph proofs were found in this bundle.";
780
+ }
781
+ const n = recordings.length;
782
+ const noun = `${n} recording${n === 1 ? "" : "s"}`;
783
+ if (result === "TRUE") {
784
+ const first = recordings[0];
785
+ if (n === 1 && first !== undefined) {
786
+ const where = first.counter !== undefined ? ` at position ${first.counter}${first.epochId !== undefined ? ` of epoch ${shortB64(first.epochId)}` : ""}` : "";
787
+ return `TRUE: this file was recorded${where}, ${shortBoundsPhrase(first.bounds)}.`;
788
+ }
789
+ 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" : ""}.`;
790
+ }
791
+ if (result === "FALSE") {
792
+ const failing = [
793
+ ...recordings.flatMap((r) => r.lines.filter((l) => l.result === "FALSE").map((l) => `${l.name}: ${l.detail}`)),
794
+ ...anchors.flatMap((a) => a.lines.filter((l) => l.result === "FALSE").map((l) => `${l.name}: ${l.detail}`)),
795
+ ...contradictions.map((c) => c.detail),
796
+ ];
797
+ return `FALSE: evidence in this bundle contradicts itself. ${failing[0] ?? ""}`.trim();
798
+ }
799
+ const open = [
800
+ ...recordings.flatMap((r) => r.lines.filter((l) => l.result === "UNDETERMINED").map((l) => l.detail)),
801
+ ...anchors.flatMap((a) => a.lines.filter((l) => l.result === "UNDETERMINED").map((l) => l.detail)),
802
+ ];
803
+ return `UNDETERMINED: nothing here contradicts the ${noun}, but the evidence does not fully decide. ${open[0] ?? ""}`.trim();
804
+ }
805
+
806
+ // ---------------------------------------------------------------------------
807
+ // Text rendering (the CLI's stdout; the browser page renders the same
808
+ // report shape into DOM, reusing every detail string verbatim)
809
+ // ---------------------------------------------------------------------------
810
+
811
+ export function renderCheckText(report: CheckReport): string {
812
+ const out: string[] = [];
813
+ const mark = (r: ThreeValued): string => (r === "TRUE" ? "TRUE " : r === "FALSE" ? "FALSE" : "UNDET");
814
+ out.push(report.summary);
815
+ out.push("");
816
+ report.recordings.forEach((rec, i) => {
817
+ out.push(
818
+ `Recording ${i + 1}${rec.filePath !== undefined ? `: ${rec.filePath}` : ""} [${rec.result}]`
819
+ );
820
+ out.push(` digest ${rec.digestB64}`);
821
+ if (rec.counter !== undefined) {
822
+ out.push(` position ${rec.counter}${rec.epochId !== undefined ? ` epoch ${rec.epochId}` : ""}`);
823
+ }
824
+ for (const line of rec.lines) out.push(` ${mark(line.result)} ${pad(line.name, 12)} ${line.detail}`);
825
+ out.push(` bounds ${rec.bounds.detail}`);
826
+ out.push("");
827
+ });
828
+ report.anchors.forEach((a, i) => {
829
+ out.push(
830
+ `Ethereum anchor ${i + 1}${a.blockNumber !== undefined ? `: block ${a.blockNumber}` : ""}${a.counter !== undefined ? ` at position ${a.counter}` : ""} [${a.result}]`
831
+ );
832
+ for (const line of a.lines) out.push(` ${mark(line.result)} ${pad(line.name, 12)} ${line.detail}`);
833
+ out.push("");
834
+ });
835
+ if (report.contradictions.length > 0) {
836
+ out.push("Contradictions");
837
+ for (const c of report.contradictions) out.push(` FALSE ${c.detail}`);
838
+ out.push("");
839
+ }
840
+ if (report.notes.length > 0) {
841
+ out.push("Notes");
842
+ for (const n of report.notes) out.push(` - ${n}`);
843
+ out.push("");
844
+ }
845
+ out.push("Not checked (no offline check can establish these)");
846
+ for (const n of report.notChecked) out.push(` - ${n}`);
847
+ out.push("");
848
+ out.push(`Result: ${report.result} (bitgraph-player ${report.evaluator.version}, bitgraph-audit ${report.evaluator.audit}, network: none)`);
849
+ return out.join("\n") + "\n";
850
+ }
851
+
852
+ /** Deterministic JSON bytes for the report: two-space indent, one trailing newline. */
853
+ export function serializeCheckReport(report: CheckReport): string {
854
+ return JSON.stringify(report, null, 2) + "\n";
855
+ }
856
+
857
+ // ---------------------------------------------------------------------------
858
+ // Small helpers
859
+ // ---------------------------------------------------------------------------
860
+
861
+ function shortB64(s: string | undefined): string {
862
+ if (s === undefined) return "(none)";
863
+ return s.length > 12 ? `${s.slice(0, 12)}…` : s;
864
+ }
865
+
866
+ function shortHex(s: string | undefined): string {
867
+ if (s === undefined) return "(none)";
868
+ return s.length > 16 ? `${s.slice(0, 16)}…` : s;
869
+ }
870
+
871
+ function pad(s: string, n: number): string {
872
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
873
+ }