@mikeargento/bitgraph-player 0.6.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.6.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,18 +26,18 @@
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
- "build:web": "node scripts/build-verify-html.mjs --out ../folder/src/verify.html",
32
+ "build:web": "node scripts/build-verify-html.mjs --out ../../website/public/verify.html",
33
33
  "typecheck:web": "tsc -p web/tsconfig.json"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=20"
37
37
  },
38
38
  "dependencies": {
39
- "@mikeargento/bitgraph-audit": "^0.2.2",
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",
@@ -116,10 +116,11 @@ describe("check: real export fixture (disk)", () => {
116
116
 
117
117
  it("brackets the recording between the two verified block headers", () => {
118
118
  const b = report.recordings[0]!.bounds;
119
- assert.equal(b.status, "bracketed");
119
+ assert.equal(b.status, "lower-bounded-with-following-anchor");
120
120
  assert.equal(b.notBefore?.blockNumber, "25735831");
121
121
  assert.equal(b.notAfter?.blockNumber, "25735832");
122
- assert.match(b.detail, /after Ethereum block 25735831 and before block 25735832/);
122
+ assert.match(b.detail, /after Ethereum block 25735831 \(header verified in this bundle\); precedes an anchor that consumed block 25735832/);
123
+ assert.match(b.detail, /rather than an upper bound/);
123
124
  });
124
125
 
125
126
  it("orders anchors by causal position, not file name", () => {
@@ -94,6 +94,8 @@ export function makeBound(partial: Partial<SegmentBound>): SegmentBound {
94
94
  weaker: partial.weaker ?? false,
95
95
  basis: partial.basis ?? "block-hash-unpredictability",
96
96
  claim: partial.claim ?? "fixture bound",
97
+ // Required since bitgraph-audit 0.3.0: a not-after bound is an assumption, never evidence.
98
+ boundClass: partial.boundClass ?? ((partial.kind ?? "not-before") === "not-before" ? "evidence" : "assumption"),
97
99
  ...(partial.blockNumber !== undefined ? { blockNumber: partial.blockNumber } : {}),
98
100
  };
99
101
  }
@@ -126,7 +128,7 @@ export function makeAudit(spec: FixtureAuditSpec): AuditResult {
126
128
  const segments = (spec.segments ?? []).map((s) => ({
127
129
  partition: { publicKeyB64: "key-A", chainId: "bitgraph:main" },
128
130
  memberProofHashes: s.members.map(proofHashOf),
129
- status: "bracketed",
131
+ status: "lower-bounded-with-following-anchor",
130
132
  lowerBounds: (s.lowerBounds ?? []).map((b) => makeBound({ ...b, kind: "not-before" })),
131
133
  upperBounds: (s.upperBounds ?? []).map((b) => makeBound({ ...b, kind: "not-after" })),
132
134
  }));
@@ -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
  }
@@ -125,12 +127,42 @@ export interface CheckBound {
125
127
  }
126
128
 
127
129
  export interface CheckBounds {
128
- status: "bracketed" | "lower-bounded" | "upper-bounded" | "unanchored";
130
+ status: "lower-bounded-with-following-anchor" | "lower-bounded" | "upper-bounded" | "unanchored";
129
131
  notBefore?: CheckBound;
130
132
  notAfter?: CheckBound;
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
@@ -618,7 +832,7 @@ function boundsFor(segment: TemporalSegment | undefined): CheckBounds {
618
832
  const notAfter = upper === undefined ? undefined : toBound(upper);
619
833
  const status: CheckBounds["status"] =
620
834
  notBefore !== undefined && notAfter !== undefined
621
- ? "bracketed"
835
+ ? "lower-bounded-with-following-anchor"
622
836
  : notBefore !== undefined
623
837
  ? "lower-bounded"
624
838
  : notAfter !== undefined
@@ -636,16 +850,22 @@ function boundsFor(segment: TemporalSegment | undefined): CheckBounds {
636
850
  };
637
851
  }
638
852
 
639
- /** "after Ethereum block A and before block B (both headers verified)…", shared by bounds.detail and the summary. */
853
+ /**
854
+ * "after Ethereum block A; precedes an anchor that consumed block B", shared by bounds.detail and the summary.
855
+ * The following anchor is deliberately NOT rendered as "before block B": a proof precedes the anchor's
856
+ * commit, and block B's timestamp bounds that commit from below, not the proof from above. Reading it as
857
+ * a ceiling assumes the anchor consumed a recently published block. An inbound anchor cannot carry a
858
+ * proof of an upper bound, so the phrase says what the evidence supports and names the assumption.
859
+ */
640
860
  function boundsPhrase(
641
861
  status: CheckBounds["status"],
642
862
  notBefore: CheckBound | undefined,
643
863
  notAfter: CheckBound | undefined
644
864
  ): string {
645
865
  switch (status) {
646
- case "bracketed":
866
+ case "lower-bounded-with-following-anchor":
647
867
  return (
648
- `after Ethereum block ${blockRef(notBefore as CheckBound)} and before block ${blockRef(notAfter as CheckBound)} (both headers verified in this bundle)` +
868
+ `after Ethereum block ${blockRef(notBefore as CheckBound)} (header verified in this bundle); precedes an anchor that consumed block ${blockRef(notAfter as CheckBound)} (header verified), an anchor-latency assumption rather than an upper bound on this recording` +
649
869
  weakerSuffix((notBefore as CheckBound).weaker || (notAfter as CheckBound).weaker)
650
870
  );
651
871
  case "lower-bounded":
@@ -689,8 +909,8 @@ function toBound(b: SegmentBound): CheckBound {
689
909
  /** The headline form of the bounds: one clause, no qualifiers (those live in bounds.detail). */
690
910
  function shortBoundsPhrase(b: CheckBounds): string {
691
911
  switch (b.status) {
692
- case "bracketed":
693
- return `between Ethereum blocks ${blockRef(b.notBefore as CheckBound)} and ${blockRef(b.notAfter as CheckBound)}`;
912
+ case "lower-bounded-with-following-anchor":
913
+ return `after Ethereum block ${blockRef(b.notBefore as CheckBound)}, then an anchor at block ${blockRef(b.notAfter as CheckBound)}`;
694
914
  case "lower-bounded":
695
915
  return `after Ethereum block ${blockRef(b.notBefore as CheckBound)}`;
696
916
  case "upper-bounded":
@@ -815,7 +1035,7 @@ function collectContradictions(audit: AuditResult): CheckLine[] {
815
1035
  return out;
816
1036
  }
817
1037
 
818
- function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors: CheckAnchor[]): string[] {
1038
+ function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors: CheckAnchor[], fuseOriginHexes?: ReadonlySet<string>): string[] {
819
1039
  const notes: string[] = [];
820
1040
 
821
1041
  // Excerpt gaps, said once, plainly.
@@ -842,7 +1062,13 @@ function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors:
842
1062
 
843
1063
  // Files that match no recording: the human hint for an edited file or a
844
1064
  // wrong drop, without claiming which.
845
- 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);
846
1072
  if (unmatched.length > 0 && recordings.length > 0) {
847
1073
  const listed = unmatched
848
1074
  .slice(0, 5)
@@ -963,6 +1189,11 @@ export function renderCheckText(report: CheckReport): string {
963
1189
  }
964
1190
  for (const line of rec.lines) out.push(` ${mark(line.result)} ${pad(line.name, 12)} ${line.detail}`);
965
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
+ }
966
1197
  out.push("");
967
1198
  });
968
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.6.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 {