@mikeargento/bitgraph-player 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikeargento/bitgraph-player",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Deterministic evaluation of causal rules over BitGraph proof bundles: a pure function from (rule, verified evidence) to a reproducible verdict.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,7 +26,7 @@
26
26
  ],
27
27
  "scripts": {
28
28
  "build": "tsc",
29
- "prepack": "tsc",
29
+ "prepack": "tsc && npm run build:web",
30
30
  "typecheck": "tsc --noEmit",
31
31
  "test": "tsc && node --test \"dist/__tests__/*.test.js\"",
32
32
  "build:web": "node scripts/build-verify-html.mjs --out ../../website/public/verify.html",
@@ -36,8 +36,8 @@
36
36
  "node": ">=20"
37
37
  },
38
38
  "dependencies": {
39
- "@mikeargento/bitgraph-audit": "^0.3.0",
40
- "@mikeargento/bitgraph-verify": "^1.3.0"
39
+ "@mikeargento/bitgraph-audit": "^0.4.0",
40
+ "@mikeargento/bitgraph-verify": "^1.4.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@noble/curves": "^2.3.0",
@@ -0,0 +1,168 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * `check` over fused recordings (profile bitgraph-fuse/1, working name).
5
+ * Fixtures are real proofs minted through the local enclave harness
6
+ * (real signatures, fake PCR0), read-only here; no ledger, no network.
7
+ */
8
+
9
+ import { describe, it } from "node:test";
10
+ import * as assert from "node:assert/strict";
11
+ import { readFile, mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { ingestBundle, ingestEntries } from "@mikeargento/bitgraph-audit";
16
+ import type { AnchorRecord, BundleEntrySource, SegmentBound, TemporalSegment } from "@mikeargento/bitgraph-audit";
17
+ import { checkIngest, fusedFloor, renderCheckText, serializeCheckReport } from "../check.js";
18
+
19
+ const FIX = fileURLToPath(new URL("../../../../src/__tests__/fuse-fixtures/", import.meta.url));
20
+ const read = (name: string) => readFile(join(FIX, name)).then((b) => new Uint8Array(b));
21
+
22
+ async function entries(spec: Record<string, string>): Promise<BundleEntrySource[]> {
23
+ const out: BundleEntrySource[] = [];
24
+ for (const [path, fixture] of Object.entries(spec)) {
25
+ const bytes = await read(fixture);
26
+ out.push({ path, open: () => bytes });
27
+ }
28
+ return out;
29
+ }
30
+
31
+ describe("fused recordings in check", () => {
32
+ it("fused bytes beside the proof: fused line TRUE, floor undetermined without anchors, statements carried", async () => {
33
+ const report = await checkIngest(await ingestEntries(await entries({ "proof.json": "trailer.proof.json", "photo.bin": "fused-trailer.bin" })));
34
+ assert.equal(report.recordings.length, 1);
35
+ const rec = report.recordings[0]!;
36
+ const fused = rec.lines.find((l) => l.name === "fused");
37
+ assert.ok(fused, "a fused line exists");
38
+ assert.equal(fused!.result, "TRUE", fused!.detail);
39
+ assert.equal(rec.lines.find((l) => l.name === "file")?.result, "TRUE");
40
+ assert.equal(rec.lines.find((l) => l.name === "signature")?.result, "TRUE");
41
+ assert.ok(rec.fused);
42
+ assert.equal(rec.fused!.category, "FUSED_DIRECT");
43
+ assert.equal(rec.fused!.evidence, "fused-bytes");
44
+ assert.equal(rec.fused!.placement, "trailer/1");
45
+ assert.equal(rec.fused!.floor, null);
46
+ assert.match(rec.fused!.floorDetail, /floor undetermined: no anchor precedes this slot in its epoch/);
47
+ assert.equal(rec.fused!.statements.length, 2);
48
+ assert.match(rec.fused!.statements[1]!, /at position \d+, and were committed no later than position \d+/);
49
+ assert.ok(rec.fused!.span && BigInt(rec.fused!.span.positions) >= 1n);
50
+ // The harness attests with a fake document, so the attestation line is FALSE
51
+ // here; that is the harness, not the fused check, and it is reported on its
52
+ // own line. The fused line above stands regardless.
53
+ assert.notEqual(rec.lines.find((l) => l.name === "attestation")?.result, "TRUE");
54
+ const text = renderCheckText(report);
55
+ assert.match(text, /fused floor floor undetermined/);
56
+ assert.match(text, /fused span slot \d+ to commit \d+/);
57
+ });
58
+
59
+ it("the original beside the proof: rebuilt, fused line TRUE, the original is noted as such and not as unmatched", async () => {
60
+ const report = await checkIngest(await ingestEntries(await entries({ "proof.json": "trailer.proof.json", "original.txt": "original.txt" })));
61
+ const rec = report.recordings[0]!;
62
+ const fused = rec.lines.find((l) => l.name === "fused")!;
63
+ assert.equal(fused.result, "TRUE", fused.detail);
64
+ assert.equal(rec.fused!.category, "FUSED_FROM_ORIGIN");
65
+ assert.equal(rec.fused!.evidence, "original");
66
+ // The committed bytes are not in hand, so the file line stays UNDETERMINED: the
67
+ // original receives the ceiling, the fused bytes the interval.
68
+ assert.equal(rec.lines.find((l) => l.name === "file")?.result, "UNDETERMINED");
69
+ assert.ok(report.notes.some((n) => /original of a fused recording: original\.txt/.test(n)), report.notes.join(" | "));
70
+ assert.ok(!report.notes.some((n) => /matches no recording here: original\.txt/.test(n)), "the original is not called unmatched");
71
+ });
72
+
73
+ it("neither bytes present: fused line UNDETERMINED, never FALSE", async () => {
74
+ const report = await checkIngest(await ingestEntries(await entries({ "proof.json": "trailer.proof.json" })));
75
+ const rec = report.recordings[0]!;
76
+ const fused = rec.lines.find((l) => l.name === "fused")!;
77
+ assert.equal(fused.result, "UNDETERMINED");
78
+ assert.match(fused.detail, /neither the fused bytes nor the original/);
79
+ assert.equal(rec.fused!.category, "NO_EVIDENCE");
80
+ assert.equal(rec.fused!.evidence, null);
81
+ });
82
+
83
+ it("a file committed under a different slot than its commitment names: fused line FALSE", async () => {
84
+ const report = await checkIngest(await ingestEntries(await entries({ "proof.json": "wrong-slot.proof.json", "photo.bin": "fused-wrong-slot.bin" })));
85
+ const rec = report.recordings[0]!;
86
+ assert.equal(rec.lines.find((l) => l.name === "fused")?.result, "FALSE");
87
+ assert.equal(rec.fused!.category, "INVALID_SLOT_COMMITMENT");
88
+ assert.equal(report.result, "FALSE");
89
+ });
90
+
91
+ it("an unregistered placement: UNDETERMINED with the placement named", async () => {
92
+ const report = await checkIngest(await ingestEntries(await entries({ "proof.json": "unregistered.proof.json", "photo.bin": "fused-unregistered.bin" })));
93
+ const rec = report.recordings[0]!;
94
+ const fused = rec.lines.find((l) => l.name === "fused")!;
95
+ assert.equal(fused.result, "UNDETERMINED");
96
+ assert.match(fused.detail, /xmp\/9/);
97
+ });
98
+
99
+ it("an ordinary recording has no fused line and no fused block", async () => {
100
+ const report = await checkIngest(await ingestEntries(await entries({ "proof.json": "recorded.proof.json", "original.txt": "original.txt" })));
101
+ const rec = report.recordings[0]!;
102
+ assert.equal(rec.lines.find((l) => l.name === "fused"), undefined);
103
+ assert.equal(rec.fused, undefined);
104
+ assert.equal(rec.lines.find((l) => l.name === "file")?.result, "TRUE");
105
+ });
106
+
107
+ it("container/1 from the PNG alone and produced/1 from its own bytes", async () => {
108
+ const a = await checkIngest(await ingestEntries(await entries({ "proof.json": "container.proof.json", "image.png": "image.png" })));
109
+ assert.equal(a.recordings[0]!.fused!.category, "FUSED_FROM_ORIGIN");
110
+ assert.equal(a.recordings[0]!.fused!.placement, "container/1");
111
+ const b = await checkIngest(await ingestEntries(await entries({ "proof.json": "produced-origin.proof.json", "record.json": "produced-origin.json" })));
112
+ assert.equal(b.recordings[0]!.fused!.category, "FUSED_DIRECT");
113
+ assert.equal(b.recordings[0]!.fused!.placement, "produced/1");
114
+ });
115
+
116
+ it("disk and memory ingests of the same fused bundle give byte-identical reports", async () => {
117
+ const dir = await mkdtemp(join(tmpdir(), "bitgraph-fuse-check-"));
118
+ try {
119
+ await mkdir(dir, { recursive: true });
120
+ await writeFile(join(dir, "proof.json"), await read("trailer.proof.json"));
121
+ await writeFile(join(dir, "original.txt"), await read("original.txt"));
122
+ const fromDisk = await checkIngest(await ingestBundle(dir));
123
+ const fromMemory = await checkIngest(await ingestEntries(await entries({ "proof.json": "trailer.proof.json", "original.txt": "original.txt" })));
124
+ assert.equal(serializeCheckReport(fromDisk), serializeCheckReport(fromMemory));
125
+ assert.equal(fromDisk.recordings[0]!.fused!.category, "FUSED_FROM_ORIGIN");
126
+ } finally {
127
+ await rm(dir, { recursive: true, force: true });
128
+ }
129
+ });
130
+ });
131
+
132
+ describe("fusedFloor", () => {
133
+ const bound = (anchorProofHash: string, blockNumber: string, timestamp: number): SegmentBound => ({
134
+ kind: "not-before", anchorProofHash, blockNumber, blockHash: "0x" + "ab".repeat(32), timestamp,
135
+ evidence: "counter-order", weaker: true, basis: "block-hash-unpredictability", boundClass: "evidence", claim: "",
136
+ });
137
+ const anchor = (proofHash: string, counter: string): AnchorRecord => ({ proofHash, chainId: "bitgraph:main", counter, metadataCorroboration: "absent" } as unknown as AnchorRecord);
138
+ const segment = (lower: SegmentBound[]): TemporalSegment => ({ lowerBounds: lower } as unknown as TemporalSegment);
139
+
140
+ it("picks the last verified anchor whose counter precedes the SLOT, not the commit", () => {
141
+ const anchors = new Map([["a1", anchor("a1", "10")], ["a2", anchor("a2", "40")], ["a3", anchor("a3", "45")]]);
142
+ const seg = segment([bound("a1", "100", 1000), bound("a2", "200", 2000), bound("a3", "300", 3000)]);
143
+ // slot 42, commit 50: a3 (counter 45) precedes the commit but not the slot.
144
+ const floor = fusedFloor(seg, anchors, "42");
145
+ assert.equal(floor?.anchorProofHash, "a2");
146
+ assert.equal(floor?.blockNumber, "200");
147
+ assert.equal(floor?.evidence, "counter-order");
148
+ });
149
+
150
+ it("no anchor before the slot: null (UNDET), never a following anchor", () => {
151
+ const anchors = new Map([["a3", anchor("a3", "45")]]);
152
+ assert.equal(fusedFloor(segment([bound("a3", "300", 3000)]), anchors, "42"), null);
153
+ assert.equal(fusedFloor(undefined, anchors, "42"), null);
154
+ assert.equal(fusedFloor(segment([bound("a3", "300", 3000)]), anchors, undefined), null);
155
+ });
156
+
157
+ it("a slot off the anchored chain has no floor", () => {
158
+ const anchors = new Map([["a1", anchor("a1", "10")]]);
159
+ assert.equal(fusedFloor(segment([bound("a1", "100", 1000)]), anchors, "42", "global"), null);
160
+ assert.equal(fusedFloor(segment([bound("a1", "100", 1000)]), anchors, "42", "bitgraph:main")?.anchorProofHash, "a1");
161
+ });
162
+
163
+ it("only not-before bounds count; not-after bounds are never a ceiling", () => {
164
+ const anchors = new Map([["a9", anchor("a9", "5")]]);
165
+ const after: SegmentBound = { ...bound("a9", "900", 9000), kind: "not-after" };
166
+ assert.equal(fusedFloor(segment([after]), anchors, "42"), null);
167
+ });
168
+ });
package/src/check.ts CHANGED
@@ -63,7 +63,9 @@ import type {
63
63
  SegmentBound,
64
64
  TemporalSegment,
65
65
  } from "@mikeargento/bitgraph-audit";
66
- import { auditIngest, AUDIT_VERSION, streamMatchedArtifacts } from "@mikeargento/bitgraph-audit";
66
+ import { auditIngest, AUDIT_VERSION, streamMatchedArtifacts, streamArtifactsByHash } from "@mikeargento/bitgraph-audit";
67
+ import { readFuseAttribution, verifyFuse, base64ToBytes, bytesToHex, resetEpochLinkState } from "@mikeargento/bitgraph-verify";
68
+ import type { FuseVerifyResult } from "@mikeargento/bitgraph-verify";
67
69
  import type { ThreeValued } from "./types.js";
68
70
  import { SIG_EVIDENCE_MAX_BYTES } from "./types.js";
69
71
  import { kleeneAll } from "./logic.js";
@@ -108,7 +110,7 @@ export const KNOWN_ENCLAVE_MEASUREMENTS: ReadonlyArray<{ pcr0: string; label: st
108
110
 
109
111
  /** One checked property, three-valued, with a plain-language reason. */
110
112
  export interface CheckLine {
111
- name: "file" | "signature" | "attestation" | "enclave" | "witness" | "contradiction" | "domain";
113
+ name: "file" | "signature" | "attestation" | "enclave" | "witness" | "contradiction" | "domain" | "fused";
112
114
  result: ThreeValued;
113
115
  detail: string;
114
116
  }
@@ -131,6 +133,36 @@ export interface CheckBounds {
131
133
  detail: string;
132
134
  }
133
135
 
136
+ /**
137
+ * The wall-clock floor of a fused artifact: the last verified anchored
138
+ * block preceding its SLOT counter in the same epoch chain. Counter-order
139
+ * evidence by construction (no hash path reaches a slot record), reported
140
+ * with the same "genuine block" assumption as every other bound.
141
+ */
142
+ export interface CheckFloor {
143
+ blockNumber?: string;
144
+ blockHash: string;
145
+ /** Unix seconds from the verified block header. */
146
+ timestamp: number;
147
+ anchorProofHash: string;
148
+ evidence: "counter-order";
149
+ }
150
+
151
+ /** What the bundle establishes about a recording marked fused (profile bitgraph-fuse/1, working name). */
152
+ export interface CheckFused {
153
+ category: FuseVerifyResult["category"] | "NO_EVIDENCE";
154
+ placement: string | null;
155
+ originDigestB64: string | null;
156
+ /** Which bytes in the bundle the commitment check ran against. */
157
+ evidence: "fused-bytes" | "original" | null;
158
+ /** The bounded statements, carrying the floor clause when a floor exists. */
159
+ statements: string[];
160
+ floor: CheckFloor | null;
161
+ floorDetail: string;
162
+ /** The causal span [N, M]. */
163
+ span: { slotCounter: string; commitCounter: string; positions: string } | null;
164
+ }
165
+
134
166
  /** A non-anchor recording in the bundle. */
135
167
  export interface CheckRecording {
136
168
  proofHash: string;
@@ -146,6 +178,8 @@ export interface CheckRecording {
146
178
  /** Kleene all over `lines`. */
147
179
  result: ThreeValued;
148
180
  bounds: CheckBounds;
181
+ /** Present exactly when the proof's signed attribution marks it fused. */
182
+ fused?: CheckFused;
149
183
  }
150
184
 
151
185
  /** An Ethereum anchor recording in the bundle. */
@@ -227,6 +261,14 @@ export interface CheckOptions {
227
261
  * embedders may supply additional candidates.
228
262
  */
229
263
  sigEvidence?: ReadonlyMap<string, Uint8Array>;
264
+ /**
265
+ * Fuse evidence by proofHash: the verifyFuse result over the fused bytes
266
+ * (preferred) or the original, whichever the bundle holds. checkIngest
267
+ * collects this from the bundle; embedders may supply it.
268
+ */
269
+ fuseEvidence?: ReadonlyMap<string, FuseVerifyResult>;
270
+ /** Content hashes (hex) of files that served as the original of a fused recording; not "unmatched". */
271
+ fuseOriginHexes?: ReadonlySet<string>;
230
272
  }
231
273
 
232
274
  // ---------------------------------------------------------------------------
@@ -270,9 +312,57 @@ export async function checkIngest(ingest: IngestResult, options?: CheckOptions):
270
312
  opts = { ...opts, sigEvidence: collected };
271
313
  }
272
314
  const audit = await auditIngest(ingest, { startedAt: "" });
315
+ if (opts?.fuseEvidence === undefined) {
316
+ const collected = await collectFuseEvidence(ingest);
317
+ opts = { ...opts, fuseEvidence: collected.evidence, fuseOriginHexes: collected.originHexes };
318
+ }
273
319
  return buildCheckReport(audit, opts);
274
320
  }
275
321
 
322
+ /**
323
+ * For every proof marked fused, find the bytes the check can run against:
324
+ * the committed (fused) bytes if the bundle holds them, else the original
325
+ * named by the signed origin digest. Runs after the audit pass and resets
326
+ * the verify library's epoch-link state around its own verifications.
327
+ */
328
+ async function collectFuseEvidence(
329
+ ingest: IngestResult
330
+ ): Promise<{ evidence: Map<string, FuseVerifyResult>; originHexes: Set<string> }> {
331
+ const evidence = new Map<string, FuseVerifyResult>();
332
+ const originHexes = new Set<string>();
333
+ const wanted: Array<{ proof: ObservedProof; artifactHex: string | null; originHex: string | null }> = [];
334
+ const hexes = new Set<string>();
335
+ for (const proof of ingest.proofs) {
336
+ const marker = readFuseAttribution(proof.proof);
337
+ if (marker === null) continue;
338
+ const artifactBytes = base64ToBytes(proof.proof.artifact.digestB64);
339
+ const artifactHex = artifactBytes !== null ? bytesToHex(artifactBytes) : null;
340
+ const originHex = marker.originDigest !== undefined ? bytesToHex(marker.originDigest) : null;
341
+ wanted.push({ proof, artifactHex, originHex });
342
+ if (artifactHex !== null) hexes.add(artifactHex);
343
+ if (originHex !== null) hexes.add(originHex);
344
+ }
345
+ if (wanted.length === 0) return { evidence, originHexes };
346
+ const bytesByHex = new Map<string, Uint8Array>();
347
+ for await (const artifact of streamArtifactsByHash(ingest, hexes)) {
348
+ if (!bytesByHex.has(artifact.sha256Hex)) bytesByHex.set(artifact.sha256Hex, artifact.bytes);
349
+ }
350
+ resetEpochLinkState();
351
+ try {
352
+ for (const w of wanted) {
353
+ const direct = w.artifactHex !== null ? bytesByHex.get(w.artifactHex) : undefined;
354
+ const original = w.originHex !== null ? bytesByHex.get(w.originHex) : undefined;
355
+ if (original !== undefined && w.originHex !== null) originHexes.add(w.originHex);
356
+ const bytes = direct ?? original;
357
+ if (bytes === undefined) continue;
358
+ evidence.set(w.proof.proofHash, await verifyFuse({ proof: w.proof.proof, bytes }));
359
+ }
360
+ } finally {
361
+ resetEpochLinkState();
362
+ }
363
+ return { evidence, originHexes };
364
+ }
365
+
276
366
  /** The pure report builder over an AuditResult. */
277
367
  export function buildCheckReport(audit: AuditResult, options?: CheckOptions): CheckReport {
278
368
  const webCrypto = options?.webCryptoAvailable ?? true;
@@ -316,7 +406,9 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
316
406
  artifactPathByProof.get(proof.proofHash),
317
407
  webCrypto,
318
408
  options?.from,
319
- options?.sigEvidence
409
+ options?.sigEvidence,
410
+ options?.fuseEvidence,
411
+ anchorByHash
320
412
  )
321
413
  );
322
414
  }
@@ -325,7 +417,7 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
325
417
  sortByPosition(recordings);
326
418
  sortByPosition(anchors);
327
419
  const contradictions = collectContradictions(audit);
328
- const notes = collectNotes(audit, recordings, anchors);
420
+ const notes = collectNotes(audit, recordings, anchors, options?.fuseOriginHexes);
329
421
  const notChecked = collectNotChecked(anchors.length > 0, options?.from?.domain);
330
422
 
331
423
  const allLines: ThreeValued[] = [
@@ -383,7 +475,9 @@ function buildRecording(
383
475
  filePath: string | undefined,
384
476
  webCrypto: boolean,
385
477
  from?: CheckDomain,
386
- sigEvidence?: ReadonlyMap<string, Uint8Array>
478
+ sigEvidence?: ReadonlyMap<string, Uint8Array>,
479
+ fuseEvidence?: ReadonlyMap<string, FuseVerifyResult>,
480
+ anchorByHash?: ReadonlyMap<string, AnchorRecord>
387
481
  ): CheckRecording {
388
482
  const lines: CheckLine[] = [];
389
483
  const v = proof.verification;
@@ -448,6 +542,19 @@ function buildRecording(
448
542
  lines.push(domainLine(proof, verified, from, sigEvidence ?? EMPTY_EVIDENCE));
449
543
  }
450
544
 
545
+ // fused: the proof's signed attribution marks a fused artifact (profile
546
+ // bitgraph-fuse/1). The commitment check runs over the fused bytes, or
547
+ // over the original by reconstruction; its floor is the last verified
548
+ // anchor preceding the SLOT, not the commit.
549
+ const marker = readFuseAttribution(proof.proof);
550
+ let fused: CheckFused | undefined;
551
+ if (marker !== null) {
552
+ const floor = fusedFloor(segment, anchorByHash, proof.slotCounter, proof.chainId);
553
+ const built = fusedLine(marker.placement, marker.originDigest !== undefined ? proof.proof.attribution?.message ?? null : null, fuseEvidence?.get(proof.proofHash), floor, proof);
554
+ lines.push(built.line);
555
+ fused = built.fused;
556
+ }
557
+
451
558
  const result = kleeneAll(lines.map((l) => l.result));
452
559
 
453
560
  return {
@@ -462,9 +569,116 @@ function buildRecording(
462
569
  lines,
463
570
  result,
464
571
  bounds: boundsFor(segment),
572
+ ...(fused !== undefined ? { fused } : {}),
573
+ };
574
+ }
575
+
576
+ /**
577
+ * The fused floor: among the segment's verified not-before anchors, those
578
+ * whose commit counter precedes the SLOT counter, tightest last (largest
579
+ * block number, else latest timestamp). Null when no verified anchor
580
+ * precedes the slot in its epoch chain.
581
+ */
582
+ export const ANCHORED_CHAIN = "bitgraph:main";
583
+
584
+ export function fusedFloor(
585
+ segment: TemporalSegment | undefined,
586
+ anchorByHash: ReadonlyMap<string, AnchorRecord> | undefined,
587
+ slotCounter: string | undefined,
588
+ chainId: string = ANCHORED_CHAIN
589
+ ): CheckFloor | null {
590
+ // Anchors exist only on the anchored chain and the anchor index carries no
591
+ // chain segment, so a slot on any other chain compares to nothing.
592
+ if (chainId !== ANCHORED_CHAIN) return null;
593
+ if (segment === undefined || anchorByHash === undefined || slotCounter === undefined || !/^[0-9]+$/.test(slotCounter)) return null;
594
+ const slot = BigInt(slotCounter);
595
+ let best: SegmentBound | undefined;
596
+ for (const b of segment.lowerBounds) {
597
+ if (b.kind !== "not-before") continue;
598
+ const anchor = anchorByHash.get(b.anchorProofHash);
599
+ if (anchor === undefined || anchor.counter === undefined || !/^[0-9]+$/.test(anchor.counter)) continue;
600
+ if (BigInt(anchor.counter) >= slot) continue;
601
+ if (best === undefined) { best = b; continue; }
602
+ const bn = b.blockNumber !== undefined ? BigInt(b.blockNumber) : BigInt(b.timestamp);
603
+ const bb = best.blockNumber !== undefined ? BigInt(best.blockNumber) : BigInt(best.timestamp);
604
+ if (bn > bb) best = b;
605
+ }
606
+ if (best === undefined) return null;
607
+ return {
608
+ ...(best.blockNumber !== undefined ? { blockNumber: best.blockNumber } : {}),
609
+ blockHash: best.blockHash,
610
+ timestamp: best.timestamp,
611
+ anchorProofHash: best.anchorProofHash,
612
+ evidence: "counter-order",
465
613
  };
466
614
  }
467
615
 
616
+ const FLOOR_UNDETERMINED = "floor undetermined: no anchor precedes this slot in its epoch";
617
+
618
+ function floorClause(floor: CheckFloor): string {
619
+ const block = floor.blockNumber !== undefined ? `#${floor.blockNumber}` : floor.blockHash;
620
+ return `anchored block ${block} (${new Date(floor.timestamp * 1000).toISOString()})`;
621
+ }
622
+
623
+ function fusedLine(
624
+ placement: string | null,
625
+ originMessage: string | null,
626
+ ev: FuseVerifyResult | undefined,
627
+ floor: CheckFloor | null,
628
+ proof: ObservedProof
629
+ ): { line: CheckLine; fused: CheckFused } {
630
+ const slot = proof.slotCounter ?? "?";
631
+ const where = placement !== null ? ` (placement ${placement})` : " (placement undeclared)";
632
+ const floorDetail = floor !== null
633
+ ? `assembled after ${floorClause(floor)}, the last verified anchor preceding slot ${slot} in this epoch`
634
+ : proof.chainId !== ANCHORED_CHAIN
635
+ ? `floor undetermined: the slot is on chain "${proof.chainId}", not the anchored chain`
636
+ : FLOOR_UNDETERMINED;
637
+ const span = ev?.span !== undefined && ev.span !== null
638
+ ? { slotCounter: ev.span.slotCounter, commitCounter: ev.span.commitCounter, positions: ev.span.positions }
639
+ : proof.slotCounter !== undefined && proof.counter !== undefined
640
+ ? { slotCounter: proof.slotCounter, commitCounter: proof.counter, positions: (BigInt(proof.counter) - BigInt(proof.slotCounter)).toString() }
641
+ : null;
642
+ const statements = (ev?.statements ?? []).map((s) =>
643
+ floor !== null ? s.replace(`at position ${slot},`, `at position ${slot}, which followed ${floorClause(floor)},`) : s
644
+ );
645
+ const base = (category: CheckFused["category"], evidence: CheckFused["evidence"]): CheckFused => ({
646
+ category,
647
+ placement: ev?.placement ?? placement,
648
+ originDigestB64: ev?.originDigestB64 ?? originMessage,
649
+ evidence,
650
+ statements,
651
+ floor,
652
+ floorDetail,
653
+ span,
654
+ });
655
+ if (ev === undefined) {
656
+ return {
657
+ line: { name: "fused", result: "UNDETERMINED", detail: `marked fused${where}, but neither the fused bytes nor ${originMessage !== null ? "the original" : "an original"} is in this bundle, so the commitment to slot ${slot} cannot be checked` },
658
+ fused: base("NO_EVIDENCE", null),
659
+ };
660
+ }
661
+ const evidence: CheckFused["evidence"] = ev.category === "FUSED_FROM_ORIGIN" || (ev.fileDigestB64 !== ev.artifactDigestB64 && ev.category !== "NO_MATCH") ? "original" : "fused-bytes";
662
+ switch (ev.category) {
663
+ case "FUSED_DIRECT":
664
+ return { line: { name: "fused", result: "TRUE", detail: `the fused bytes carry the commitment to slot ${slot}${where}` }, fused: base(ev.category, "fused-bytes") };
665
+ case "FUSED_FROM_ORIGIN":
666
+ return { line: { name: "fused", result: "TRUE", detail: `the original rebuilds the committed fused bytes${where}; they carry the commitment to slot ${slot}` }, fused: base(ev.category, "original") };
667
+ case "INVALID_SLOT_COMMITMENT":
668
+ return { line: { name: "fused", result: "FALSE", detail: `the fused bytes do not carry the commitment to this proof's slot ${slot}: ${ev.reason ?? ""}` }, fused: base(ev.category, evidence) };
669
+ case "INVALID_ORIGIN_ATTRIBUTION":
670
+ return { line: { name: "fused", result: "FALSE", detail: `the declared origin contradicts the origin inside the fused bytes: ${ev.reason ?? ""}` }, fused: base(ev.category, evidence) };
671
+ case "RECONSTRUCTION_MISMATCH":
672
+ return { line: { name: "fused", result: "FALSE", detail: `the file matching the declared origin does not rebuild the committed fused bytes: ${ev.reason ?? ""}` }, fused: base(ev.category, "original") };
673
+ case "UNDETERMINED_PLACEMENT":
674
+ return { line: { name: "fused", result: "UNDETERMINED", detail: `${ev.reason ?? "the placement is not registered"}; this verifier cannot check the commitment` }, fused: base(ev.category, evidence) };
675
+ case "INVALID_UNDERLYING_PROOF":
676
+ return { line: { name: "fused", result: "UNDETERMINED", detail: "not checked: the proof itself does not verify (see the signature line)" }, fused: base(ev.category, evidence) };
677
+ default:
678
+ return { line: { name: "fused", result: "UNDETERMINED", detail: `the commitment could not be checked: ${ev.reason ?? ev.category}` }, fused: base(ev.category, evidence) };
679
+ }
680
+ }
681
+
468
682
  function attestationLine(
469
683
  record: ProofAttestationRecord | undefined,
470
684
  webCrypto: boolean
@@ -821,7 +1035,7 @@ function collectContradictions(audit: AuditResult): CheckLine[] {
821
1035
  return out;
822
1036
  }
823
1037
 
824
- function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors: CheckAnchor[]): string[] {
1038
+ function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors: CheckAnchor[], fuseOriginHexes?: ReadonlySet<string>): string[] {
825
1039
  const notes: string[] = [];
826
1040
 
827
1041
  // Excerpt gaps, said once, plainly.
@@ -848,7 +1062,13 @@ function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors:
848
1062
 
849
1063
  // Files that match no recording: the human hint for an edited file or a
850
1064
  // wrong drop, without claiming which.
851
- const unmatched = audit.ingest.artifacts.filter((a) => a.matchedProofHashes.length === 0);
1065
+ const originals = audit.ingest.artifacts.filter((a) => a.matchedProofHashes.length === 0 && fuseOriginHexes?.has(a.sha256Hex) === true);
1066
+ if (originals.length > 0) {
1067
+ notes.push(
1068
+ `${originals.length} file${originals.length === 1 ? "" : "s"} in this bundle ${originals.length === 1 ? "is" : "are"} the original of a fused recording: ${originals.slice(0, 5).map((a) => a.paths[0] ?? "(unnamed)").join(", ")}${originals.length > 5 ? ", …" : ""}. The original itself receives only the ceiling (it existed no later than the commit); the fused bytes receive the interval`
1069
+ );
1070
+ }
1071
+ const unmatched = audit.ingest.artifacts.filter((a) => a.matchedProofHashes.length === 0 && fuseOriginHexes?.has(a.sha256Hex) !== true);
852
1072
  if (unmatched.length > 0 && recordings.length > 0) {
853
1073
  const listed = unmatched
854
1074
  .slice(0, 5)
@@ -969,6 +1189,11 @@ export function renderCheckText(report: CheckReport): string {
969
1189
  }
970
1190
  for (const line of rec.lines) out.push(` ${mark(line.result)} ${pad(line.name, 12)} ${line.detail}`);
971
1191
  out.push(` bounds ${rec.bounds.detail}`);
1192
+ if (rec.fused !== undefined) {
1193
+ out.push(` fused floor ${rec.fused.floorDetail}`);
1194
+ if (rec.fused.span !== null) out.push(` fused span slot ${rec.fused.span.slotCounter} to commit ${rec.fused.span.commitCounter} (${rec.fused.span.positions} positions)`);
1195
+ for (const s of rec.fused.statements) out.push(` statement ${s}`);
1196
+ }
972
1197
  out.push("");
973
1198
  });
974
1199
  report.anchors.forEach((a, i) => {
package/src/verdict.ts CHANGED
@@ -31,7 +31,7 @@ import type { DeclaredEntry, Resolution, Rule, Verdict } from "./types.js";
31
31
  * A unit test asserts this equals package.json's version, so the
32
32
  * constant cannot drift silently across releases.
33
33
  */
34
- export const PLAYER_VERSION = "0.7.0";
34
+ export const PLAYER_VERSION = "0.8.0";
35
35
 
36
36
  /** The player package's own version. */
37
37
  export function playerVersion(): string {