@mikeargento/bitgraph-player 0.15.0 → 0.16.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.15.0",
3
+ "version": "0.16.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
  "keywords": [
6
6
  "bitgraph",
@@ -43,8 +43,8 @@
43
43
  "node": ">=20"
44
44
  },
45
45
  "dependencies": {
46
- "@mikeargento/bitgraph-audit": "^0.7.0",
47
- "@mikeargento/bitgraph-verify": "^1.7.0"
46
+ "@mikeargento/bitgraph-audit": "^0.10.0",
47
+ "@mikeargento/bitgraph-verify": "^1.17.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@noble/curves": "^2.3.0",
@@ -0,0 +1,187 @@
1
+ // Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * Base floors (enclave v10) in `check` and `compare`.
5
+ *
6
+ * From enclave v10 a recording signs its floor as a Base block
7
+ * (commit.slotFloor) instead of an Ethereum anchor. An audit that reads it
8
+ * reports a not-before segment bound with source "signed-floor" and chain
9
+ * "base", a TemporalAnalysis.signedFloors record per floor, and
10
+ * floorProblems. The Player reads those fields structurally (it still builds
11
+ * against an older audit), so these tests hand it AuditResult shapes with
12
+ * them, exactly as the fixtures elsewhere do. No ledger writes, no network.
13
+ */
14
+
15
+ import { describe, it } from "node:test";
16
+ import * as assert from "node:assert/strict";
17
+ import type { AuditResult, ObservedProof, SegmentBound, TemporalSegment } from "@mikeargento/bitgraph-audit";
18
+ import { buildCheckReport, renderCheckText } from "../check.js";
19
+ import type { SignedFloorRecord } from "../check.js";
20
+ import { compare } from "../order.js";
21
+ import { digestFor, makeAudit, makeBound, proofHashOf } from "./fixtures.js";
22
+
23
+ const BASE_BLOCK = 52271417;
24
+ const BASE_TIME = 1791332181;
25
+ const BASE_HASH = "0x71d926aeae9847fcc15fad2d4de8871923f56862d42db7a7463e95a838f8f9b0";
26
+
27
+ function complete(audit: AuditResult, temporal: Record<string, unknown>): AuditResult {
28
+ return {
29
+ ...audit,
30
+ anomalies: { anomalies: [], divergences: [], boundaryEntryPoints: [] },
31
+ authorities: { groups: [], anomalies: [], sharedSignersAcrossEpochs: [] },
32
+ anchors: { anchors: [], metadataOnlyProofHashes: [], findings: [] },
33
+ witnesses: { outcomes: [], findings: [] },
34
+ attestations: {
35
+ records: [],
36
+ findings: [],
37
+ counts: {
38
+ proofsWithDeclaredMeasurement: 0, proofsWithDocument: 0, documentsValidated: 0, documentsFailed: 0,
39
+ pcr0Matches: 0, pcr0Mismatches: 0, userDataBound: 0, userDataUnbound: 0,
40
+ },
41
+ },
42
+ temporal: { ...audit.temporal, ...temporal },
43
+ } as unknown as AuditResult;
44
+ }
45
+
46
+ function baseBound(proofName: string, opts?: Partial<{ timeSource: "header" | "signed"; evidence: SegmentBound["evidence"] | "signed-floor"; blockNumber: number; timestamp: number }>): SegmentBound {
47
+ return {
48
+ ...makeBound({
49
+ kind: "not-before",
50
+ anchorProofHash: proofHashOf(proofName),
51
+ blockHash: BASE_HASH,
52
+ blockNumber: String(opts?.blockNumber ?? BASE_BLOCK),
53
+ timestamp: opts?.timestamp ?? BASE_TIME,
54
+ evidence: (opts?.evidence ?? "signed-floor") as SegmentBound["evidence"],
55
+ claim: "fixture Base floor bound",
56
+ }),
57
+ source: "signed-floor",
58
+ chain: "base",
59
+ timeSource: opts?.timeSource ?? "header",
60
+ } as SegmentBound;
61
+ }
62
+
63
+ function floorRecord(proofName: string, opts?: Partial<SignedFloorRecord>): SignedFloorRecord {
64
+ return {
65
+ proofHash: proofHashOf(proofName),
66
+ chain: "base",
67
+ blockNumber: BASE_BLOCK,
68
+ blockHash: BASE_HASH,
69
+ blockTimestamp: BASE_TIME,
70
+ header: "checked",
71
+ headerPath: "base-floor/floor-header.json",
72
+ bound: "not-before",
73
+ ...(opts ?? {}),
74
+ };
75
+ }
76
+
77
+ function segment(names: string[], lower: SegmentBound[], upper: SegmentBound[] = [], epochId = "E10"): TemporalSegment {
78
+ return {
79
+ partition: { publicKeyB64: "key-A", chainId: "bitgraph:main", epochId },
80
+ memberProofHashes: names.map(proofHashOf),
81
+ status: lower.length > 0 && upper.length > 0 ? "lower-bounded-with-following-anchor" : lower.length > 0 ? "lower-bounded" : upper.length > 0 ? "upper-bounded" : "ordered-but-unanchored",
82
+ lowerBounds: lower,
83
+ upperBounds: upper,
84
+ } as TemporalSegment;
85
+ }
86
+
87
+ function oneRecording(temporal: Record<string, unknown>, attribution?: Record<string, string>): AuditResult {
88
+ const audit = makeAudit({ proofs: [{ name: "r", digestB64: digestFor("r"), epochId: "E10", counter: "4", slotCounter: "3" }] });
89
+ if (attribution !== undefined) {
90
+ (audit.ingest.proofs[0]!.proof as unknown as Record<string, unknown>)["attribution"] = attribution;
91
+ }
92
+ return complete(audit, temporal);
93
+ }
94
+
95
+ describe("check: a recording that signs a Base floor", () => {
96
+ it("is bounded after its Base block, with the header checked, and no upper bound", () => {
97
+ const report = buildCheckReport(oneRecording({ segments: [segment(["r"], [baseBound("r")])], signedFloors: [floorRecord("r")] }));
98
+ const rec = report.recordings[0]!;
99
+ assert.equal(rec.bounds.status, "lower-bounded");
100
+ assert.equal(rec.bounds.notBefore?.chain, "base");
101
+ assert.equal(rec.bounds.notBefore?.evidence, "signed-floor");
102
+ assert.equal(rec.bounds.notBefore?.blockNumber, String(BASE_BLOCK));
103
+ assert.equal(rec.bounds.notAfter, undefined);
104
+ assert.match(rec.bounds.detail, /after Base block 52271417 \(its floor, header checked in this bundle\)/);
105
+ assert.doesNotMatch(rec.bounds.detail, /Ethereum/);
106
+ assert.ok(report.notChecked.some((n) => /Base floor blocks are Base's own/.test(n)));
107
+ assert.doesNotMatch(renderCheckText(report), /[–—]/);
108
+ });
109
+
110
+ it("without the header, says the block needs a Base lookup", () => {
111
+ const report = buildCheckReport(
112
+ oneRecording({ segments: [segment(["r"], [baseBound("r", { timeSource: "signed" })])], signedFloors: [floorRecord("r", { header: "not-carried" })] })
113
+ );
114
+ assert.match(report.recordings[0]!.bounds.detail, /confirming the block needs a Base lookup/);
115
+ });
116
+
117
+ it("compares bounds across chains by time, never by block number", () => {
118
+ // Ethereum block 25,000,000 is later in time than this Base block,
119
+ // whose NUMBER is larger: the tightest not-before is the Ethereum one.
120
+ const eth = makeBound({ kind: "not-before", anchorProofHash: "anchor-eth", blockNumber: "25000000", timestamp: BASE_TIME + 600 });
121
+ const report = buildCheckReport(oneRecording({ segments: [segment(["r"], [baseBound("r"), eth])], signedFloors: [floorRecord("r")] }));
122
+ const nb = report.recordings[0]!.bounds.notBefore!;
123
+ assert.equal(nb.chain, undefined);
124
+ assert.equal(nb.blockNumber, "25000000");
125
+ });
126
+
127
+ it("a fuse/3 recording's fused floor is the Base block it signs", () => {
128
+ const report = buildCheckReport(
129
+ oneRecording({ segments: [segment(["r"], [baseBound("r")])], signedFloors: [floorRecord("r")] }, { name: "bitgraph-fuse/3", title: "trailer/1" })
130
+ );
131
+ const fused = report.recordings[0]!.fused!;
132
+ assert.equal(fused.floor?.chain, "base");
133
+ assert.equal(fused.floor?.evidence, "signed-floor");
134
+ assert.equal(fused.floor?.blockNumber, String(BASE_BLOCK));
135
+ assert.match(fused.floorDetail, /Base block #52271417 .*the Base block this recording signs as its floor/);
136
+ });
137
+
138
+ it("a withheld floor bounds nothing and says why", () => {
139
+ const why = "the floor block is stamped after the attestation document was made";
140
+ const report = buildCheckReport(
141
+ oneRecording({ segments: [segment(["r"], [])], signedFloors: [floorRecord("r", { bound: "withheld", withheldReason: why })] }, { name: "bitgraph-fuse/3", title: "trailer/1" })
142
+ );
143
+ const rec = report.recordings[0]!;
144
+ assert.equal(rec.bounds.notBefore, undefined);
145
+ assert.match(rec.bounds.detail, /not used as a bound/);
146
+ assert.equal(rec.fused!.floor, null);
147
+ assert.match(rec.fused!.floorDetail, /not used as a bound/);
148
+ });
149
+
150
+ it("a floor problem (a recording that signs two floors) is a contradiction: FALSE", () => {
151
+ const report = buildCheckReport(
152
+ oneRecording({
153
+ segments: [segment(["r"], [])],
154
+ floorProblems: [{ code: "floor-ambiguous", proofHash: proofHashOf("r"), message: "the proof signs two floors" }],
155
+ })
156
+ );
157
+ assert.equal(report.result, "FALSE");
158
+ assert.ok(report.contradictions.some((c) => c.detail.startsWith("floor-ambiguous")));
159
+ });
160
+ });
161
+
162
+ describe("compare: a Base floor orders no epoch", () => {
163
+ it("an anchor's not-after in one epoch and a later Base floor in another answer unordered", () => {
164
+ const base = makeAudit({
165
+ proofs: [
166
+ { name: "old", digestB64: digestFor("old"), epochId: "E9", counter: "2" },
167
+ { name: "new", digestB64: digestFor("new"), epochId: "E10", counter: "2", publicKeyB64: "key-B" },
168
+ ],
169
+ partitions: [
170
+ { epochId: "E9", members: ["old"] },
171
+ { epochId: "E10", members: ["new"], publicKeyB64: "key-B" },
172
+ ],
173
+ });
174
+ const anchorUpper = makeBound({ kind: "not-after", anchorProofHash: "anchor-9", blockNumber: "25000000", timestamp: 1_700_000_000 });
175
+ const audit = complete(base, {
176
+ segments: [segment(["old"], [], [anchorUpper], "E9"), segment(["new"], [baseBound("new")], [], "E10")],
177
+ signedFloors: [floorRecord("new")],
178
+ });
179
+ const byName = (n: string): ObservedProof => audit.ingest.proofs.find((p) => p.proofHash === proofHashOf(n))!;
180
+ assert.equal(compare(byName("old"), byName("new"), audit).relation, "unordered");
181
+ assert.equal(compare(byName("new"), byName("old"), audit).relation, "unordered");
182
+ // The same shape with an anchor not-before on the new side does order them (old behaviour kept).
183
+ const anchorLower = makeBound({ kind: "not-before", anchorProofHash: "anchor-10", blockNumber: "25000100", timestamp: 1_700_001_200 });
184
+ const anchored = complete(base, { segments: [segment(["old"], [], [anchorUpper], "E9"), segment(["new"], [anchorLower], [], "E10")] });
185
+ assert.equal(compare(byName("old"), byName("new"), anchored).relation, "before");
186
+ });
187
+ });
@@ -147,7 +147,7 @@ describe("check: real export fixture (disk)", () => {
147
147
  it("recognizes the enclave v5 measurement from PINS.md", () => {
148
148
  const enclave = report.recordings[0]!.lines.find((l) => l.name === "enclave")!;
149
149
  assert.match(enclave.detail, /enclave v5/);
150
- assert.equal(KNOWN_ENCLAVE_MEASUREMENTS.length, 8);
150
+ assert.equal(KNOWN_ENCLAVE_MEASUREMENTS.length, 9);
151
151
  });
152
152
 
153
153
  it("serializes deterministically: no timestamps, no machine paths", async () => {
package/src/check.ts CHANGED
@@ -70,6 +70,43 @@ import type { ThreeValued } from "./types.js";
70
70
  import { kleeneAll } from "./logic.js";
71
71
  import { PLAYER_VERSION } from "./verdict.js";
72
72
 
73
+ // ---------------------------------------------------------------------------
74
+ // Base floors (enclave v10), read structurally
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * What an audit that reads Base floors adds (bitgraph-audit after 0.9.0:
79
+ * TemporalAnalysis.signedFloors and floorProblems, and source / chain /
80
+ * timeSource on a segment bound). Read structurally, so the Player builds and
81
+ * behaves exactly as before against an audit without them; every field is
82
+ * optional and absent means an Ethereum anchor bound.
83
+ * TODO(release): bump the bitgraph-audit dependency to the release that
84
+ * carries signedFloors, then these can be the audit's own exported types.
85
+ */
86
+ export interface SignedFloorRecord {
87
+ proofHash: string;
88
+ chain: "base";
89
+ blockNumber: number;
90
+ blockHash: string;
91
+ blockTimestamp: number;
92
+ header: "checked" | "not-carried";
93
+ headerPath?: string;
94
+ bound: "not-before" | "withheld";
95
+ withheldReason?: string;
96
+ }
97
+ export interface FloorProblem {
98
+ code: string;
99
+ proofHash?: string;
100
+ path?: string;
101
+ message: string;
102
+ }
103
+ type FloorAwareBound = SegmentBound & { source?: "signed-floor"; chain?: "base"; timeSource?: "header" | "signed" };
104
+
105
+ function floorView(audit: AuditResult): { floors: SignedFloorRecord[]; problems: FloorProblem[] } {
106
+ const t = audit.temporal as { signedFloors?: SignedFloorRecord[]; floorProblems?: FloorProblem[] };
107
+ return { floors: t.signedFloors ?? [], problems: t.floorProblems ?? [] };
108
+ }
109
+
73
110
  // ---------------------------------------------------------------------------
74
111
  // Declared knowledge: published enclave measurements
75
112
  // ---------------------------------------------------------------------------
@@ -119,7 +156,12 @@ export const KNOWN_ENCLAVE_MEASUREMENTS: ReadonlyArray<{ pcr0: string; label: st
119
156
  {
120
157
  pcr0: "934feb8bb6f4f7e2d2f85d902a7d5edd0981f706d9d2385638988ac096a05ea0583c3d00eef2a7947865ec66efc1fcf8",
121
158
  label: "enclave v9 (reproducible; the allocation returns the floor anchor, for bitgraph-fuse/2)",
122
- period: "2026-09-30 onward",
159
+ period: "2026-09-30 to 2026-10-07",
160
+ },
161
+ {
162
+ pcr0: "5a947cc66095adcceefa9e5ece5d1416dfe08c3470df1bcaa5b2bc5267b0480e6cdc172fe077cd06b0afb07614307973",
163
+ label: "enclave v10 (reproducible; the floor is a Base block fixed at allocation, for bitgraph-fuse/3)",
164
+ period: "2026-10-08 onward",
123
165
  },
124
166
  ];
125
167
 
@@ -134,15 +176,24 @@ export interface CheckLine {
134
176
  detail: string;
135
177
  }
136
178
 
137
- /** A verified Ethereum bound on a recording, from a witness in the bundle. */
179
+ /**
180
+ * A verified bound on a recording: an Ethereum anchor's, from a witness in
181
+ * the bundle, or (enclave v10) the Base block a recording signs as its floor,
182
+ * which is a not-before only.
183
+ */
138
184
  export interface CheckBound {
185
+ /** Absent for Ethereum (an anchor); "base" for a Base floor (commit.slotFloor). */
186
+ chain?: "base";
139
187
  blockNumber?: string;
140
188
  blockHash: string;
141
- /** Unix seconds from the verified block header. */
189
+ /** Unix seconds: from the verified block header, or for a Base floor without one, the time the proof signs. */
142
190
  timestamp: number;
191
+ /** The anchor proof, or for a Base floor the recording that signs it. */
143
192
  anchorProofHash: string;
144
- evidence: "chain-link" | "counter-order";
193
+ evidence: "chain-link" | "counter-order" | "signed-floor";
145
194
  weaker: boolean;
195
+ /** Base floors only: "header" checked in the bundle, or "signed" (confirming the block needs a Base lookup). */
196
+ timeSource?: "header" | "signed";
146
197
  }
147
198
 
148
199
  export interface CheckBounds {
@@ -153,18 +204,26 @@ export interface CheckBounds {
153
204
  }
154
205
 
155
206
  /**
156
- * The wall-clock floor of a fused artifact: the last verified anchored
157
- * block preceding its SLOT counter in the same epoch chain. Counter-order
158
- * evidence by construction (no hash path reaches a slot record), reported
159
- * with the same "genuine block" assumption as every other bound.
207
+ * The wall-clock floor of a fused artifact. Ethereum floors (enclave v9 and
208
+ * before): the last verified anchored block preceding its SLOT counter in
209
+ * the same epoch chain, counter-order evidence by construction (no hash path
210
+ * reaches a slot record). Base floors (enclave v10): the block the recording
211
+ * itself signs as commit.slotFloor, which a bitgraph-fuse/3 commitment binds
212
+ * ("signed-floor"). Either is reported with the same "genuine block"
213
+ * assumption as every other bound.
160
214
  */
161
215
  export interface CheckFloor {
216
+ /** Absent for Ethereum; "base" for the recording's own Base floor. */
217
+ chain?: "base";
162
218
  blockNumber?: string;
163
219
  blockHash: string;
164
- /** Unix seconds from the verified block header. */
220
+ /** Unix seconds from the verified block header, or for a Base floor without one, the signed time. */
165
221
  timestamp: number;
222
+ /** The anchor proof, or for a Base floor the recording itself. */
166
223
  anchorProofHash: string;
167
- evidence: "counter-order";
224
+ evidence: "counter-order" | "signed-floor";
225
+ /** Base floors only: "header" checked in the bundle, or "signed" (confirming the block needs a Base lookup). */
226
+ timeSource?: "header" | "signed";
168
227
  }
169
228
 
170
229
  /** What the bundle establishes about a recording marked fused (profile bitgraph-fuse/1, working name). */
@@ -337,6 +396,7 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
337
396
  const attestationByHash = new Map<string, ProofAttestationRecord>(
338
397
  audit.attestations.records.map((r) => [r.proofHash, r])
339
398
  );
399
+ const floorByHash = new Map<string, SignedFloorRecord>(floorView(audit).floors.map((f) => [f.proofHash, f]));
340
400
  const segmentByHash = new Map<string, TemporalSegment>();
341
401
  for (const segment of audit.temporal.segments) {
342
402
  for (const hash of segment.memberProofHashes) segmentByHash.set(hash, segment);
@@ -373,7 +433,8 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
373
433
  artifactPathByProof.get(proof.proofHash),
374
434
  webCrypto,
375
435
  options?.fuseEvidence,
376
- anchorByHash
436
+ anchorByHash,
437
+ floorByHash.get(proof.proofHash)
377
438
  )
378
439
  );
379
440
  }
@@ -383,7 +444,7 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
383
444
  sortByPosition(anchors);
384
445
  const contradictions = collectContradictions(audit);
385
446
  const notes = collectNotes(audit, recordings, anchors, options?.fuseOriginHexes);
386
- const notChecked = collectNotChecked(anchors.length > 0);
447
+ const notChecked = collectNotChecked(anchors.length > 0, floorByHash.size > 0);
387
448
 
388
449
  const allLines: ThreeValued[] = [
389
450
  ...recordings.map((r) => r.result),
@@ -437,7 +498,8 @@ function buildRecording(
437
498
  filePath: string | undefined,
438
499
  webCrypto: boolean,
439
500
  fuseEvidence?: ReadonlyMap<string, FuseVerifyResult>,
440
- anchorByHash?: ReadonlyMap<string, AnchorRecord>
501
+ anchorByHash?: ReadonlyMap<string, AnchorRecord>,
502
+ signedFloor?: SignedFloorRecord
441
503
  ): CheckRecording {
442
504
  const lines: CheckLine[] = [];
443
505
  const v = proof.verification;
@@ -493,12 +555,13 @@ function buildRecording(
493
555
  // fused: the proof's signed attribution marks a fused artifact (profile
494
556
  // bitgraph-fuse/1). The commitment check runs over the fused bytes, or
495
557
  // over the original by reconstruction; its floor is the last verified
496
- // anchor preceding the SLOT, not the commit.
558
+ // anchor preceding the SLOT, not the commit, or for a recording that signs
559
+ // a Base floor (enclave v10, bitgraph-fuse/3) that block itself.
497
560
  const marker = readFuseAttribution(proof.proof);
498
561
  let fused: CheckFused | undefined;
499
562
  if (marker !== null) {
500
- const floor = fusedFloor(segment, anchorByHash, proof.slotCounter, proof.chainId);
501
- const built = fusedLine(marker.placement, marker.originDigest !== undefined ? proof.proof.attribution?.message ?? null : null, fuseEvidence?.get(proof.proofHash), floor, proof);
563
+ const floor = signedFloor !== undefined ? ownBaseFloor(signedFloor) : fusedFloor(segment, anchorByHash, proof.slotCounter, proof.chainId);
564
+ const built = fusedLine(marker.placement, marker.originDigest !== undefined ? proof.proof.attribution?.message ?? null : null, fuseEvidence?.get(proof.proofHash), floor, proof, signedFloor);
502
565
  lines.push(built.line);
503
566
  fused = built.fused;
504
567
  }
@@ -516,7 +579,7 @@ function buildRecording(
516
579
  ...(filePath !== undefined && v?.tier === "full" ? { filePath } : {}),
517
580
  lines,
518
581
  result,
519
- bounds: boundsFor(segment),
582
+ bounds: boundsFor(segment, signedFloor),
520
583
  ...(fused !== undefined ? { fused } : {}),
521
584
  };
522
585
  }
@@ -561,11 +624,29 @@ export function fusedFloor(
561
624
  };
562
625
  }
563
626
 
627
+ /**
628
+ * The fused floor of a recording that signs a Base floor (enclave v10): the
629
+ * block itself, which a bitgraph-fuse/3 commitment binds. Null when the audit
630
+ * withheld it as a bound (the reason is in the floor detail).
631
+ */
632
+ export function ownBaseFloor(floor: SignedFloorRecord): CheckFloor | null {
633
+ if (floor.bound !== "not-before") return null;
634
+ return {
635
+ chain: "base",
636
+ blockNumber: String(floor.blockNumber),
637
+ blockHash: floor.blockHash,
638
+ timestamp: floor.blockTimestamp,
639
+ anchorProofHash: floor.proofHash,
640
+ evidence: "signed-floor",
641
+ timeSource: floor.header === "checked" ? "header" : "signed",
642
+ };
643
+ }
644
+
564
645
  const FLOOR_UNDETERMINED = "floor undetermined: no anchor precedes this slot in its epoch";
565
646
 
566
647
  function floorClause(floor: CheckFloor): string {
567
648
  const block = floor.blockNumber !== undefined ? `#${floor.blockNumber}` : floor.blockHash;
568
- return `anchored block ${block} (${new Date(floor.timestamp * 1000).toISOString()})`;
649
+ return `${floor.chain === "base" ? "Base" : "anchored"} block ${block} (${new Date(floor.timestamp * 1000).toISOString()})`;
569
650
  }
570
651
 
571
652
  function fusedLine(
@@ -573,13 +654,19 @@ function fusedLine(
573
654
  originMessage: string | null,
574
655
  ev: FuseVerifyResult | undefined,
575
656
  floor: CheckFloor | null,
576
- proof: ObservedProof
657
+ proof: ObservedProof,
658
+ signedFloor?: SignedFloorRecord
577
659
  ): { line: CheckLine; fused: CheckFused } {
578
660
  const slot = proof.slotCounter ?? "?";
579
661
  const where = placement !== null ? ` (placement ${placement})` : " (placement undeclared)";
580
- const floorDetail = floor !== null
662
+ const floorDetail = floor !== null && floor.chain === "base"
663
+ ? `assembled after ${floorClause(floor)}, the Base block this recording signs as its floor` +
664
+ (floor.timeSource === "signed" ? "; no header for it is in this bundle, so confirming the block needs a Base lookup" : "")
665
+ : floor !== null
581
666
  ? `assembled after ${floorClause(floor)}, the last verified anchor preceding slot ${slot} in this epoch`
582
- : proof.chainId !== ANCHORED_CHAIN
667
+ : signedFloor !== undefined
668
+ ? `floor undetermined: the Base floor this recording signs is not used as a bound (${signedFloor.withheldReason ?? "withheld"})`
669
+ : proof.chainId !== ANCHORED_CHAIN
583
670
  ? `floor undetermined: the slot is on chain "${proof.chainId}", not the anchored chain`
584
671
  : FLOOR_UNDETERMINED;
585
672
  const span = ev?.span !== undefined && ev.span !== null
@@ -713,9 +800,13 @@ function enclaveLine(attestedPcr0: string | undefined, attestationResult: ThreeV
713
800
  };
714
801
  }
715
802
 
716
- function boundsFor(segment: TemporalSegment | undefined): CheckBounds {
803
+ function boundsFor(segment: TemporalSegment | undefined, signedFloor?: SignedFloorRecord): CheckBounds {
804
+ const withheld =
805
+ signedFloor !== undefined && signedFloor.bound === "withheld"
806
+ ? `; the Base floor it signs is not used as a bound (${signedFloor.withheldReason ?? "withheld"})`
807
+ : "";
717
808
  if (segment === undefined) {
718
- return { status: "unanchored", detail: "no verified Ethereum anchor bounds this recording in this bundle" };
809
+ return { status: "unanchored", detail: `no verified Ethereum anchor bounds this recording in this bundle${withheld}` };
719
810
  }
720
811
  const lower = tightest(segment.lowerBounds, "not-before");
721
812
  const upper = tightest(segment.upperBounds, "not-after");
@@ -731,8 +822,8 @@ function boundsFor(segment: TemporalSegment | undefined): CheckBounds {
731
822
  : "unanchored";
732
823
  const detail =
733
824
  status === "unanchored"
734
- ? "no verified Ethereum anchor bounds this recording in this bundle"
735
- : `recorded ${boundsPhrase(status, notBefore, notAfter)}`;
825
+ ? `no verified Ethereum anchor bounds this recording in this bundle${withheld}`
826
+ : `recorded ${boundsPhrase(status, notBefore, notAfter)}${withheld}`;
736
827
  return {
737
828
  status,
738
829
  ...(notBefore !== undefined ? { notBefore } : {}),
@@ -756,11 +847,11 @@ function boundsPhrase(
756
847
  switch (status) {
757
848
  case "lower-bounded-with-following-anchor":
758
849
  return (
759
- `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` +
850
+ `${afterBlock(notBefore as CheckBound)}; precedes an anchor that consumed block ${blockRef(notAfter as CheckBound)} (header verified), an anchor-latency assumption rather than an upper bound on this recording` +
760
851
  weakerSuffix((notBefore as CheckBound).weaker || (notAfter as CheckBound).weaker)
761
852
  );
762
853
  case "lower-bounded":
763
- return `after Ethereum block ${blockRef(notBefore as CheckBound)} (header verified in this bundle); no verified upper bound here` + weakerSuffix((notBefore as CheckBound).weaker);
854
+ return `${afterBlock(notBefore as CheckBound)}; no verified upper bound here` + weakerSuffix((notBefore as CheckBound).weaker);
764
855
  case "upper-bounded":
765
856
  return `with no verified lower bound in this bundle; an anchor that consumed block ${blockRef(notAfter as CheckBound)} followed (header verified), which is not an upper bound` + weakerSuffix((notAfter as CheckBound).weaker);
766
857
  default:
@@ -768,17 +859,31 @@ function boundsPhrase(
768
859
  }
769
860
  }
770
861
 
862
+ /**
863
+ * "after Ethereum block N (header verified in this bundle)", or for a Base
864
+ * floor (enclave v10) "after Base block N" with where its time comes from.
865
+ */
866
+ function afterBlock(b: CheckBound): string {
867
+ if (b.chain !== "base") return `after Ethereum block ${blockRef(b)} (header verified in this bundle)`;
868
+ return b.timeSource === "header"
869
+ ? `after Base block ${blockRef(b)} (its floor, header checked in this bundle)`
870
+ : `after Base block ${blockRef(b)} (its floor, at the time the proof signs; confirming the block needs a Base lookup)`;
871
+ }
872
+
771
873
  /**
772
874
  * The tightest bound of a kind: for not-before the LARGEST block number
773
875
  * (latest verified anchor known to precede), for not-after the SMALLEST.
876
+ * Block numbers compare only within one chain: across Ethereum and Base
877
+ * (enclave v10 floors) the block times compare instead.
774
878
  * Ties resolve by preferring chain-link evidence over counter-order.
775
879
  */
776
- function tightest(bounds: SegmentBound[], kind: "not-before" | "not-after"): SegmentBound | undefined {
880
+ function tightest(bounds: FloorAwareBound[], kind: "not-before" | "not-after"): FloorAwareBound | undefined {
777
881
  const ofKind = bounds.filter((b) => b.kind === kind);
778
882
  if (ofKind.length === 0) return undefined;
779
883
  const sorted = [...ofKind].sort((a, b) => {
780
- const an = a.blockNumber !== undefined ? BigInt(a.blockNumber) : BigInt(a.timestamp);
781
- const bn = b.blockNumber !== undefined ? BigInt(b.blockNumber) : BigInt(b.timestamp);
884
+ const sameChain = a.chain === b.chain;
885
+ const an = sameChain && a.blockNumber !== undefined ? BigInt(a.blockNumber) : BigInt(a.timestamp);
886
+ const bn = sameChain && b.blockNumber !== undefined ? BigInt(b.blockNumber) : BigInt(b.timestamp);
782
887
  if (an !== bn) return kind === "not-before" ? (an > bn ? -1 : 1) : an < bn ? -1 : 1;
783
888
  if (a.weaker !== b.weaker) return a.weaker ? 1 : -1;
784
889
  return 0;
@@ -786,14 +891,16 @@ function tightest(bounds: SegmentBound[], kind: "not-before" | "not-after"): Seg
786
891
  return sorted[0];
787
892
  }
788
893
 
789
- function toBound(b: SegmentBound): CheckBound {
894
+ function toBound(b: FloorAwareBound): CheckBound {
790
895
  return {
896
+ ...(b.chain === "base" ? { chain: "base" as const } : {}),
791
897
  ...(b.blockNumber !== undefined ? { blockNumber: b.blockNumber } : {}),
792
898
  blockHash: b.blockHash,
793
899
  timestamp: b.timestamp,
794
900
  anchorProofHash: b.anchorProofHash,
795
901
  evidence: b.evidence,
796
902
  weaker: b.weaker,
903
+ ...(b.timeSource !== undefined ? { timeSource: b.timeSource } : {}),
797
904
  };
798
905
  }
799
906
 
@@ -801,9 +908,9 @@ function toBound(b: SegmentBound): CheckBound {
801
908
  function shortBoundsPhrase(b: CheckBounds): string {
802
909
  switch (b.status) {
803
910
  case "lower-bounded-with-following-anchor":
804
- return `after Ethereum block ${blockRef(b.notBefore as CheckBound)}, then an anchor at block ${blockRef(b.notAfter as CheckBound)}`;
911
+ return `after ${chainWord(b.notBefore as CheckBound)} block ${blockRef(b.notBefore as CheckBound)}, then an anchor at block ${blockRef(b.notAfter as CheckBound)}`;
805
912
  case "lower-bounded":
806
- return `after Ethereum block ${blockRef(b.notBefore as CheckBound)}`;
913
+ return `after ${chainWord(b.notBefore as CheckBound)} block ${blockRef(b.notBefore as CheckBound)}`;
807
914
  case "upper-bounded":
808
915
  return `with no lower bound in this bundle, then an anchor at block ${blockRef(b.notAfter as CheckBound)} (not an upper bound)`;
809
916
  default:
@@ -811,6 +918,10 @@ function shortBoundsPhrase(b: CheckBounds): string {
811
918
  }
812
919
  }
813
920
 
921
+ function chainWord(b: CheckBound): string {
922
+ return b.chain === "base" ? "Base" : "Ethereum";
923
+ }
924
+
814
925
  function blockRef(b: CheckBound): string {
815
926
  return b.blockNumber !== undefined ? b.blockNumber : b.blockHash;
816
927
  }
@@ -915,6 +1026,12 @@ function collectContradictions(audit: AuditResult): CheckLine[] {
915
1026
  push(`${finding.code}: ${finding.message}`);
916
1027
  }
917
1028
  }
1029
+ // A floor problem (enclave v10 floors): a recording that signs two floors,
1030
+ // a malformed or off-schedule Base floor, a floor header that contradicts
1031
+ // the signed floor or matches none. Evidence that contradicts itself.
1032
+ for (const problem of floorView(audit).problems) {
1033
+ push(`${problem.code}: ${problem.message}${problem.path !== undefined ? ` (${problem.path})` : ""}`);
1034
+ }
918
1035
  // An embedded proofHash that does not match the recomputed one: the
919
1036
  // stored proof file was altered after the ledger wrote it, or is not
920
1037
  // what it claims.
@@ -1000,13 +1117,18 @@ function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors:
1000
1117
  return notes;
1001
1118
  }
1002
1119
 
1003
- function collectNotChecked(hasAnchors: boolean): string[] {
1120
+ function collectNotChecked(hasAnchors: boolean, hasBaseFloors = false): string[] {
1004
1121
  const out: string[] = [];
1005
1122
  if (hasAnchors) {
1006
1123
  out.push(
1007
1124
  "whether the anchored Ethereum blocks are canonical: their headers are recomputed here, but canonicality needs an Ethereum node or a block explorer"
1008
1125
  );
1009
1126
  }
1127
+ if (hasBaseFloors) {
1128
+ out.push(
1129
+ "whether the Base floor blocks are Base's own: a header in the bundle is checked here against the signed block, but that the block is on Base's chain needs a Base node or a block explorer"
1130
+ );
1131
+ }
1010
1132
  out.push(
1011
1133
  "whether the anchors in this bundle are the ones BitGraph published: this is an offline check; the published anchors are listed at bitgraph.ing/ledger"
1012
1134
  );
package/src/order.ts CHANGED
@@ -31,7 +31,10 @@
31
31
  * - "anchor-bounds": strict block separation between verified segment
32
32
  * bounds; always assumption-dependent (anchor freshness). If BOTH
33
33
  * directions satisfy strict separation the evidence is contradictory
34
- * and answers nothing.
34
+ * and answers nothing. Only ANCHOR bounds take part: a Base floor
35
+ * (enclave v10, a segment bound with source "signed-floor") is a
36
+ * not-before for its own recording and orders nothing across epochs,
37
+ * and its block numbers are Base's, never comparable to Ethereum's.
35
38
  * Epoch pairs supported by neither are concurrent-or-unordered, and the
36
39
  * honest answer is "unordered", never a coin flip.
37
40
  */
@@ -215,13 +218,21 @@ function boundsStrictlyOrdered(upper: SegmentBound, lower: SegmentBound): boolea
215
218
  return upper.timestamp < lower.timestamp;
216
219
  }
217
220
 
221
+ /** A Base floor bound (enclave v10; audit field source "signed-floor"), read structurally. */
222
+ function isFloorBound(b: SegmentBound): boolean {
223
+ return (b as { source?: string }).source !== undefined;
224
+ }
225
+
218
226
  function anchorPrecedes(
219
227
  firstSegment: TemporalSegment,
220
228
  secondSegment: TemporalSegment
221
229
  ): { weaker: boolean; upper: SegmentBound; lower: SegmentBound } | undefined {
222
230
  let best: { weaker: boolean; upper: SegmentBound; lower: SegmentBound } | undefined;
223
231
  for (const upper of firstSegment.upperBounds) {
232
+ if (isFloorBound(upper)) continue;
224
233
  for (const lower of secondSegment.lowerBounds) {
234
+ // Anchor bounds only: a Base floor orders no epoch.
235
+ if (isFloorBound(lower)) continue;
225
236
  if (!boundsStrictlyOrdered(upper, lower)) continue;
226
237
  const weaker = upper.weaker || lower.weaker;
227
238
  if (best === undefined || (best.weaker && !weaker)) {
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.15.0";
34
+ export const PLAYER_VERSION = "0.16.0";
35
35
 
36
36
  /** The player package's own version. */
37
37
  export function playerVersion(): string {