@mikeargento/bitgraph-audit 0.1.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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -0
  3. package/dist/anchors.d.ts +31 -0
  4. package/dist/anchors.d.ts.map +1 -0
  5. package/dist/anchors.js +116 -0
  6. package/dist/anchors.js.map +1 -0
  7. package/dist/anomalies.d.ts +42 -0
  8. package/dist/anomalies.d.ts.map +1 -0
  9. package/dist/anomalies.js +642 -0
  10. package/dist/anomalies.js.map +1 -0
  11. package/dist/attestation.d.ts +20 -0
  12. package/dist/attestation.d.ts.map +1 -0
  13. package/dist/attestation.js +795 -0
  14. package/dist/attestation.js.map +1 -0
  15. package/dist/audit.d.ts +19 -0
  16. package/dist/audit.d.ts.map +1 -0
  17. package/dist/audit.js +116 -0
  18. package/dist/audit.js.map +1 -0
  19. package/dist/authority.d.ts +37 -0
  20. package/dist/authority.d.ts.map +1 -0
  21. package/dist/authority.js +126 -0
  22. package/dist/authority.js.map +1 -0
  23. package/dist/aws-nitro-root-ca.d.ts +24 -0
  24. package/dist/aws-nitro-root-ca.d.ts.map +1 -0
  25. package/dist/aws-nitro-root-ca.js +44 -0
  26. package/dist/aws-nitro-root-ca.js.map +1 -0
  27. package/dist/cli.d.ts +3 -0
  28. package/dist/cli.d.ts.map +1 -0
  29. package/dist/cli.js +257 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/contents-hash.d.ts +30 -0
  32. package/dist/contents-hash.d.ts.map +1 -0
  33. package/dist/contents-hash.js +73 -0
  34. package/dist/contents-hash.js.map +1 -0
  35. package/dist/export.d.ts +64 -0
  36. package/dist/export.d.ts.map +1 -0
  37. package/dist/export.js +192 -0
  38. package/dist/export.js.map +1 -0
  39. package/dist/index.d.ts +31 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +17 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/ingest.d.ts +36 -0
  44. package/dist/ingest.d.ts.map +1 -0
  45. package/dist/ingest.js +828 -0
  46. package/dist/ingest.js.map +1 -0
  47. package/dist/reconstruct.d.ts +40 -0
  48. package/dist/reconstruct.d.ts.map +1 -0
  49. package/dist/reconstruct.js +400 -0
  50. package/dist/reconstruct.js.map +1 -0
  51. package/dist/report-json.d.ts +3 -0
  52. package/dist/report-json.d.ts.map +1 -0
  53. package/dist/report-json.js +290 -0
  54. package/dist/report-json.js.map +1 -0
  55. package/dist/report-md.d.ts +3 -0
  56. package/dist/report-md.d.ts.map +1 -0
  57. package/dist/report-md.js +853 -0
  58. package/dist/report-md.js.map +1 -0
  59. package/dist/rlp.d.ts +37 -0
  60. package/dist/rlp.d.ts.map +1 -0
  61. package/dist/rlp.js +120 -0
  62. package/dist/rlp.js.map +1 -0
  63. package/dist/tar.d.ts +47 -0
  64. package/dist/tar.d.ts.map +1 -0
  65. package/dist/tar.js +483 -0
  66. package/dist/tar.js.map +1 -0
  67. package/dist/temporal.d.ts +55 -0
  68. package/dist/temporal.d.ts.map +1 -0
  69. package/dist/temporal.js +461 -0
  70. package/dist/temporal.js.map +1 -0
  71. package/dist/types.d.ts +1389 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/types.js +3 -0
  74. package/dist/types.js.map +1 -0
  75. package/dist/validity.d.ts +21 -0
  76. package/dist/validity.d.ts.map +1 -0
  77. package/dist/validity.js +86 -0
  78. package/dist/validity.js.map +1 -0
  79. package/dist/verify-tiers.d.ts +11 -0
  80. package/dist/verify-tiers.d.ts.map +1 -0
  81. package/dist/verify-tiers.js +120 -0
  82. package/dist/verify-tiers.js.map +1 -0
  83. package/dist/witness.d.ts +25 -0
  84. package/dist/witness.d.ts.map +1 -0
  85. package/dist/witness.js +308 -0
  86. package/dist/witness.js.map +1 -0
  87. package/package.json +42 -0
  88. package/src/anchors.ts +168 -0
  89. package/src/anomalies.ts +773 -0
  90. package/src/attestation.ts +878 -0
  91. package/src/audit.ts +136 -0
  92. package/src/authority.ts +165 -0
  93. package/src/aws-nitro-root-ca.ts +47 -0
  94. package/src/cli.ts +284 -0
  95. package/src/contents-hash.ts +95 -0
  96. package/src/export.ts +278 -0
  97. package/src/index.ts +122 -0
  98. package/src/ingest.ts +967 -0
  99. package/src/reconstruct.ts +491 -0
  100. package/src/report-json.ts +331 -0
  101. package/src/report-md.ts +1067 -0
  102. package/src/rlp.ts +142 -0
  103. package/src/tar.ts +587 -0
  104. package/src/temporal.ts +629 -0
  105. package/src/types.ts +1528 -0
  106. package/src/validity.ts +90 -0
  107. package/src/verify-tiers.ts +137 -0
  108. package/src/witness.ts +407 -0
@@ -0,0 +1,629 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * bitgraph-audit temporal bounds
5
+ *
6
+ * Turns verified anchor witnesses into one-sided wall-clock bounds on
7
+ * segments of the observed record. Only anchors with a VERIFIED witness
8
+ * (witness.ts) contribute: an anchor without one still establishes causal
9
+ * order, but confers no wall-clock evidence. Nothing here reads a block
10
+ * number as a time, an unsigned metadata timestamp, or a manifest time.
11
+ *
12
+ * Bound semantics, stated once and enforced everywhere:
13
+ *
14
+ * not-before (lower bound): a proof causally AFTER an anchor that
15
+ * consumed the hash of a block published at time T was COMMITTED no
16
+ * earlier than T. Grounded in block-hash unpredictability: the hash did
17
+ * not exist before T and the covered proofs embed it through the chain.
18
+ * This additionally assumes the anchored header is a genuine, publicly
19
+ * published Ethereum block, which this offline audit cannot confirm: it
20
+ * checks the header's structure and hash binding, not proof-of-work,
21
+ * consensus, or chain membership. Every not-before claim states that
22
+ * assumption. Sound along chain-link evidence, subject to it.
23
+ *
24
+ * not-after (upper bound): a proof causally BEFORE an anchor existed
25
+ * before that anchor's commit, and the consumed block proves the commit
26
+ * came AT OR AFTER its timestamp T, not how promptly. Reading T as a
27
+ * wall-clock ceiling therefore additionally assumes the anchor consumed
28
+ * a recently published block. The deployed anchor service commits the
29
+ * latest block on a short interval, but that is service behavior, not
30
+ * proof; the assumption is stated on every not-after bound and on every
31
+ * cross-epoch ordering derived from one. This is a deliberate
32
+ * correction toward honesty over the looser "existed by T" phrasing:
33
+ * the anchor mechanism is inbound-only (a block hash committed INTO the
34
+ * chain), and an inbound commitment cannot cryptographically upper-bound
35
+ * prior events.
36
+ *
37
+ * Evidence classes: a bound holds along a verified prevB64 hash-link path
38
+ * ("chain-link") or, weaker, by commit-counter ordering within the same
39
+ * partition ("counter-order", which relies on the authority's counter
40
+ * discipline rather than verifiable links). Every bound record states
41
+ * which class supports it. Bounds never cross partitions; epoch-level
42
+ * aggregation only collects the per-partition results.
43
+ *
44
+ * Bounds attach to segments, never to individual proofs, and a one-sided
45
+ * bound is never presented as an interval. Segments with no verified
46
+ * anchor evidence are ordered-but-unanchored. Cross-epoch ordering pairs
47
+ * are evidence, never divergence; overlapping or absent bounds mean
48
+ * concurrent-or-unordered.
49
+ *
50
+ * Run after verifyObservedProofs, reconstructChains, identifyAnchors, and
51
+ * verifyAnchorWitnesses. Populates EpochRecord.anchorBounds on the given
52
+ * reconstruction (the typed Phase 4c extension point) and returns the
53
+ * segment-level analysis.
54
+ */
55
+
56
+ import type {
57
+ AnchorIdentification,
58
+ AnchorOrderedPair,
59
+ AnchorWitnessAnalysis,
60
+ BoundEvidence,
61
+ ChainPartition,
62
+ EpochAnchorBound,
63
+ IngestResult,
64
+ ObservedProof,
65
+ ReconstructionResult,
66
+ SegmentBound,
67
+ TemporalAnalysis,
68
+ TemporalSegment,
69
+ } from "./types.js";
70
+ import { byCounterThenHash, parseCounter, pushMap } from "./validity.js";
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Public API
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export function deriveTemporalBounds(
77
+ ingest: IngestResult,
78
+ reconstruction: ReconstructionResult,
79
+ identification: AnchorIdentification,
80
+ witnessAnalysis: AnchorWitnessAnalysis
81
+ ): TemporalAnalysis {
82
+ const byHash = new Map<string, ObservedProof>(ingest.proofs.map((p) => [p.proofHash, p]));
83
+
84
+ // Verified wall-clock evidence per anchor. Multiple verified witnesses
85
+ // for one anchor are necessarily identical (the hash comparison pins
86
+ // the exact header bytes); the first is kept.
87
+ const evidence = new Map<string, VerifiedAnchorEvidence>();
88
+ for (const outcome of witnessAnalysis.outcomes) {
89
+ if (!outcome.verified || outcome.anchorProofHash === undefined) continue;
90
+ if (evidence.has(outcome.anchorProofHash)) continue;
91
+ evidence.set(outcome.anchorProofHash, {
92
+ anchorProofHash: outcome.anchorProofHash,
93
+ timestamp: outcome.timestamp as number,
94
+ blockHash: outcome.computedBlockHash as string,
95
+ ...(outcome.blockNumber !== undefined ? { blockNumber: outcome.blockNumber } : {}),
96
+ });
97
+ }
98
+
99
+ const verifiedAnchorProofHashes = [...evidence.keys()].sort();
100
+ const unverifiedAnchorProofHashes = identification.anchors
101
+ .map((a) => a.proofHash)
102
+ .filter((h) => !evidence.has(h))
103
+ .sort();
104
+
105
+ // Per-partition segments.
106
+ const segments: TemporalSegment[] = [];
107
+ for (const partition of reconstruction.partitions) {
108
+ segments.push(...buildPartitionSegments(partition, byHash, evidence));
109
+ }
110
+
111
+ // Epoch-level aggregation and cross-epoch ordering.
112
+ const anchorOrderedPairs = aggregateEpochs(reconstruction, segments);
113
+
114
+ return {
115
+ segments,
116
+ anchorOrderedPairs,
117
+ verifiedAnchorProofHashes,
118
+ unverifiedAnchorProofHashes,
119
+ };
120
+ }
121
+
122
+ interface VerifiedAnchorEvidence {
123
+ anchorProofHash: string;
124
+ timestamp: number;
125
+ blockHash: string;
126
+ blockNumber?: string;
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Per-partition bound computation
131
+ // ---------------------------------------------------------------------------
132
+
133
+ interface MemberBounds {
134
+ member: ObservedProof;
135
+ lower: SegmentBound[];
136
+ upper: SegmentBound[];
137
+ }
138
+
139
+ function buildPartitionSegments(
140
+ partition: ChainPartition,
141
+ byHash: Map<string, ObservedProof>,
142
+ evidence: Map<string, VerifiedAnchorEvidence>
143
+ ): TemporalSegment[] {
144
+ const members = partition.memberProofHashes.map((h) => byHash.get(h) as ObservedProof);
145
+ const memberSet = new Map<string, ObservedProof>(members.map((m) => [m.proofHash, m]));
146
+
147
+ const anchors = members.filter((m) => evidence.has(m.proofHash));
148
+
149
+ // Hash-link structure within the partition.
150
+ const successors = new Map<string, ObservedProof[]>();
151
+ for (const m of members) {
152
+ if (m.prevB64 === undefined) continue;
153
+ const pred = memberSet.get(m.prevB64);
154
+ if (pred === undefined || pred.proofHash === m.proofHash) continue;
155
+ pushMap(successors, pred.proofHash, m);
156
+ }
157
+
158
+ // For each verified anchor: members causally after it (descendants via
159
+ // hash links) and causally before it (ancestors via the prevB64 walk).
160
+ const chainAfter = new Map<string, Set<string>>();
161
+ const chainBefore = new Map<string, Set<string>>();
162
+ for (const anchor of anchors) {
163
+ chainAfter.set(anchor.proofHash, collectDescendants(anchor, successors));
164
+ chainBefore.set(anchor.proofHash, collectAncestors(anchor, memberSet));
165
+ }
166
+
167
+ // Candidate bounds per member.
168
+ const memberBounds: MemberBounds[] = members.map((member) => {
169
+ const lowerCandidates = new Map<string, BoundEvidence>();
170
+ const upperCandidates = new Map<string, BoundEvidence>();
171
+ const memberCounter = parseCounter(member.counter);
172
+
173
+ for (const anchor of anchors) {
174
+ const anchorHash = anchor.proofHash;
175
+ const anchorCounter = parseCounter(anchor.counter);
176
+
177
+ // Lower (not-before): the anchor itself consumed the block hash, so
178
+ // it is its own strongest lower bound; descendants inherit through
179
+ // the chain; counter ordering is the weaker fallback.
180
+ if (member.proofHash === anchorHash || (chainAfter.get(anchorHash) as Set<string>).has(member.proofHash)) {
181
+ lowerCandidates.set(anchorHash, "chain-link");
182
+ } else if (
183
+ memberCounter !== undefined &&
184
+ anchorCounter !== undefined &&
185
+ memberCounter > anchorCounter
186
+ ) {
187
+ if (!lowerCandidates.has(anchorHash)) lowerCandidates.set(anchorHash, "counter-order");
188
+ }
189
+
190
+ // Upper (not-after): members the anchor's chain state commits to;
191
+ // counter ordering as the weaker fallback. Never from the anchor to
192
+ // itself.
193
+ if ((chainBefore.get(anchorHash) as Set<string>).has(member.proofHash)) {
194
+ upperCandidates.set(anchorHash, "chain-link");
195
+ } else if (
196
+ member.proofHash !== anchorHash &&
197
+ memberCounter !== undefined &&
198
+ anchorCounter !== undefined &&
199
+ memberCounter < anchorCounter
200
+ ) {
201
+ if (!upperCandidates.has(anchorHash)) upperCandidates.set(anchorHash, "counter-order");
202
+ }
203
+ }
204
+
205
+ return {
206
+ member,
207
+ lower: selectBounds("not-before", lowerCandidates, evidence),
208
+ upper: selectBounds("not-after", upperCandidates, evidence),
209
+ };
210
+ });
211
+
212
+ // Group members sharing an identical bound set into segments.
213
+ const groups = new Map<string, MemberBounds[]>();
214
+ for (const mb of memberBounds) {
215
+ pushMap(groups, boundSetKey(mb), mb);
216
+ }
217
+
218
+ const segments: TemporalSegment[] = [];
219
+ for (const group of groups.values()) {
220
+ const groupMembers = group.map((g) => g.member).sort(byCounterThenHash);
221
+ const first = group[0] as MemberBounds;
222
+ const lowerBounds = first.lower;
223
+ const upperBounds = first.upper;
224
+ const status =
225
+ lowerBounds.length > 0 && upperBounds.length > 0
226
+ ? ("bracketed" as const)
227
+ : lowerBounds.length > 0
228
+ ? ("lower-bounded" as const)
229
+ : upperBounds.length > 0
230
+ ? ("upper-bounded" as const)
231
+ : ("ordered-but-unanchored" as const);
232
+
233
+ const range = positionRange(groupMembers);
234
+ segments.push({
235
+ partition: partition.key,
236
+ memberProofHashes: groupMembers.map((m) => m.proofHash),
237
+ ...(range !== undefined ? { positionRange: range } : {}),
238
+ status,
239
+ lowerBounds,
240
+ upperBounds,
241
+ });
242
+ }
243
+
244
+ segments.sort(compareSegments);
245
+ return segments;
246
+ }
247
+
248
+ /**
249
+ * Select the reported bounds from the candidate set: the tightest bound
250
+ * overall (max timestamp for not-before, min for not-after), plus the
251
+ * tightest chain-link bound when the overall tightest rests only on
252
+ * counter ordering. At most two entries, tightest first.
253
+ */
254
+ function selectBounds(
255
+ kind: "not-before" | "not-after",
256
+ candidates: Map<string, BoundEvidence>,
257
+ evidence: Map<string, VerifiedAnchorEvidence>
258
+ ): SegmentBound[] {
259
+ if (candidates.size === 0) return [];
260
+
261
+ const entries = [...candidates.entries()].map(([anchorHash, evidenceKind]) => ({
262
+ anchorHash,
263
+ evidenceKind,
264
+ info: evidence.get(anchorHash) as VerifiedAnchorEvidence,
265
+ }));
266
+
267
+ const better = (
268
+ a: (typeof entries)[number],
269
+ b: (typeof entries)[number] | undefined
270
+ ): boolean => {
271
+ if (b === undefined) return true;
272
+ if (a.info.timestamp !== b.info.timestamp) {
273
+ return kind === "not-before"
274
+ ? a.info.timestamp > b.info.timestamp
275
+ : a.info.timestamp < b.info.timestamp;
276
+ }
277
+ // Tie: prefer chain-link evidence, then the lower anchor hash.
278
+ if (a.evidenceKind !== b.evidenceKind) return a.evidenceKind === "chain-link";
279
+ return a.anchorHash < b.anchorHash;
280
+ };
281
+
282
+ let best: (typeof entries)[number] | undefined;
283
+ let bestChain: (typeof entries)[number] | undefined;
284
+ for (const entry of entries) {
285
+ if (better(entry, best)) best = entry;
286
+ if (entry.evidenceKind === "chain-link" && better(entry, bestChain)) bestChain = entry;
287
+ }
288
+
289
+ const bounds: SegmentBound[] = [makeBound(kind, best as (typeof entries)[number])];
290
+ if (
291
+ bestChain !== undefined &&
292
+ ((best as (typeof entries)[number]).anchorHash !== bestChain.anchorHash ||
293
+ (best as (typeof entries)[number]).evidenceKind !== "chain-link")
294
+ ) {
295
+ bounds.push(makeBound(kind, bestChain));
296
+ }
297
+ return bounds;
298
+ }
299
+
300
+ function makeBound(
301
+ kind: "not-before" | "not-after",
302
+ entry: { anchorHash: string; evidenceKind: BoundEvidence; info: VerifiedAnchorEvidence }
303
+ ): SegmentBound {
304
+ const { info, evidenceKind } = entry;
305
+ const iso = new Date(info.timestamp * 1000).toISOString();
306
+ const blockName =
307
+ info.blockNumber !== undefined ? `Ethereum block ${info.blockNumber}` : "an Ethereum block";
308
+ const evidenceSentence =
309
+ evidenceKind === "chain-link"
310
+ ? "Evidence: a verified hash-link path connects these proofs and the anchor."
311
+ : "Evidence: commit-counter ordering within the partition only, which relies on the " +
312
+ "authority's counter discipline rather than verifiable hash links (weaker).";
313
+ const claim =
314
+ kind === "not-before"
315
+ ? `These proofs were committed no earlier than ${iso} (unix ${info.timestamp}), the ` +
316
+ `timestamp of ${blockName}: the block hash was unpredictable before that time and the ` +
317
+ `anchor commit consumed it. Reading this as a wall-clock floor additionally assumes the ` +
318
+ `anchored header is a genuine, publicly published Ethereum block: this offline audit checks ` +
319
+ `the header's structure and hash binding, not proof-of-work, consensus, or chain membership, ` +
320
+ `so it cannot confirm the block is real. ${evidenceSentence}`
321
+ : `These proofs existed before the anchor commit that consumed the hash of ${blockName} ` +
322
+ `(block timestamp ${iso}, unix ${info.timestamp}). The block timestamp proves the anchor ` +
323
+ `commit came at or after it, not how promptly; reading it as a wall-clock ceiling ` +
324
+ `additionally assumes the anchor consumed a recently published block. ${evidenceSentence}`;
325
+
326
+ return {
327
+ kind,
328
+ anchorProofHash: entry.anchorHash,
329
+ ...(info.blockNumber !== undefined ? { blockNumber: info.blockNumber } : {}),
330
+ blockHash: info.blockHash,
331
+ timestamp: info.timestamp,
332
+ evidence: evidenceKind,
333
+ weaker: evidenceKind === "counter-order",
334
+ basis: kind === "not-before" ? "block-hash-unpredictability" : "causal-precedence",
335
+ claim,
336
+ };
337
+ }
338
+
339
+ function boundSetKey(mb: MemberBounds): string {
340
+ const part = (bounds: SegmentBound[]): string =>
341
+ bounds.map((b) => `${b.anchorProofHash}:${b.evidence}`).join(",");
342
+ return `${part(mb.lower)}|${part(mb.upper)}`;
343
+ }
344
+
345
+ function collectDescendants(
346
+ anchor: ObservedProof,
347
+ successors: Map<string, ObservedProof[]>
348
+ ): Set<string> {
349
+ const reached = new Set<string>();
350
+ const stack = [...(successors.get(anchor.proofHash) ?? [])];
351
+ while (stack.length > 0) {
352
+ const node = stack.pop() as ObservedProof;
353
+ if (reached.has(node.proofHash)) continue;
354
+ reached.add(node.proofHash);
355
+ for (const next of successors.get(node.proofHash) ?? []) stack.push(next);
356
+ }
357
+ return reached;
358
+ }
359
+
360
+ function collectAncestors(
361
+ anchor: ObservedProof,
362
+ memberSet: Map<string, ObservedProof>
363
+ ): Set<string> {
364
+ const reached = new Set<string>();
365
+ let cursor: ObservedProof | undefined = anchor;
366
+ while (cursor !== undefined && cursor.prevB64 !== undefined) {
367
+ const pred: ObservedProof | undefined = memberSet.get(cursor.prevB64);
368
+ if (pred === undefined || reached.has(pred.proofHash) || pred.proofHash === anchor.proofHash) {
369
+ break;
370
+ }
371
+ reached.add(pred.proofHash);
372
+ cursor = pred;
373
+ }
374
+ return reached;
375
+ }
376
+
377
+ function positionRange(
378
+ members: ObservedProof[]
379
+ ): { min: string; max: string } | undefined {
380
+ let min: bigint | undefined;
381
+ let max: bigint | undefined;
382
+ for (const m of members) {
383
+ for (const value of [m.counter, m.slotCounter]) {
384
+ const n = parseCounter(value);
385
+ if (n === undefined) continue;
386
+ if (min === undefined || n < min) min = n;
387
+ if (max === undefined || n > max) max = n;
388
+ }
389
+ }
390
+ return min !== undefined && max !== undefined
391
+ ? { min: String(min), max: String(max) }
392
+ : undefined;
393
+ }
394
+
395
+ function compareSegments(a: TemporalSegment, b: TemporalSegment): number {
396
+ const ma = a.positionRange?.min;
397
+ const mb = b.positionRange?.min;
398
+ if (ma !== undefined && mb !== undefined && ma !== mb) {
399
+ return BigInt(ma) < BigInt(mb) ? -1 : 1;
400
+ }
401
+ if (ma !== undefined && mb === undefined) return -1;
402
+ if (ma === undefined && mb !== undefined) return 1;
403
+ const ha = a.memberProofHashes[0] ?? "";
404
+ const hb = b.memberProofHashes[0] ?? "";
405
+ return ha < hb ? -1 : ha > hb ? 1 : 0;
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // Epoch aggregation and cross-epoch ordering
410
+ // ---------------------------------------------------------------------------
411
+
412
+ interface CoverageRepresentative {
413
+ timestamp: number;
414
+ anchorProofHash: string;
415
+ blockHash: string;
416
+ blockNumber?: string;
417
+ /** Evidence class of the bound chosen as the conservative representative. */
418
+ evidence: BoundEvidence;
419
+ /** True when that representative rests on counter-order evidence. */
420
+ weaker: boolean;
421
+ }
422
+
423
+ interface EpochCoverage {
424
+ totalProofCount: number;
425
+ /** Distinct members covered by any not-before bound, and the most conservative (minimum) covering timestamp. */
426
+ lowerCovered: Set<string>;
427
+ lowerMin?: CoverageRepresentative;
428
+ /** Distinct members covered by any not-after bound, and the most conservative (maximum) covering timestamp. */
429
+ upperCovered: Set<string>;
430
+ upperMax?: CoverageRepresentative;
431
+ }
432
+
433
+ /**
434
+ * Populate EpochRecord.anchorBounds and derive covered-portion ordering
435
+ * pairs.
436
+ *
437
+ * The epoch-level not-before is the MINIMUM timestamp over the members'
438
+ * lower bounds (every covered member is not-before at least that), and
439
+ * the epoch-level not-after is the MAXIMUM over the members' upper bounds
440
+ * (every covered member existed before an anchor commit at or after that).
441
+ * These conservative representatives make the cross-epoch comparison
442
+ * sound for the covered portions: epoch A's not-after strictly below
443
+ * epoch B's not-before orders the covered portion of A before the covered
444
+ * portion of B, subject to the not-after freshness assumption.
445
+ */
446
+ /** Sentence appended to an epoch-level claim when its representative bound rests on counter-order evidence. */
447
+ function weakerCaveat(weaker: boolean): string {
448
+ return weaker
449
+ ? " The representative bound rests on commit-counter ordering rather than a verified hash-link " +
450
+ "path, which relies on the authority's counter discipline (weaker evidence)."
451
+ : "";
452
+ }
453
+
454
+ function aggregateEpochs(
455
+ reconstruction: ReconstructionResult,
456
+ segments: TemporalSegment[]
457
+ ): AnchorOrderedPair[] {
458
+ const coverage = new Map<string, EpochCoverage>();
459
+ for (const epoch of reconstruction.epochRelationships.epochs) {
460
+ coverage.set(epoch.epochId, {
461
+ totalProofCount: epoch.proofCount,
462
+ lowerCovered: new Set(),
463
+ upperCovered: new Set(),
464
+ });
465
+ }
466
+
467
+ for (const segment of segments) {
468
+ const epochId = segment.partition.epochId;
469
+ if (epochId === undefined) continue;
470
+ const cov = coverage.get(epochId);
471
+ if (cov === undefined) continue;
472
+
473
+ // The tightest bound is entry 0 by construction; every listed bound
474
+ // covers the members, so the conservative representative scans all.
475
+ // Conservative representative: minimum timestamp for not-before. At an
476
+ // equal timestamp, prefer chain-link evidence so the epoch bound is
477
+ // never marked weaker when a hash-link bound justifies the same time.
478
+ for (const bound of segment.lowerBounds) {
479
+ for (const h of segment.memberProofHashes) cov.lowerCovered.add(h);
480
+ if (
481
+ cov.lowerMin === undefined ||
482
+ bound.timestamp < cov.lowerMin.timestamp ||
483
+ (bound.timestamp === cov.lowerMin.timestamp && cov.lowerMin.weaker && !bound.weaker)
484
+ ) {
485
+ cov.lowerMin = {
486
+ timestamp: bound.timestamp,
487
+ anchorProofHash: bound.anchorProofHash,
488
+ blockHash: bound.blockHash,
489
+ ...(bound.blockNumber !== undefined ? { blockNumber: bound.blockNumber } : {}),
490
+ evidence: bound.evidence,
491
+ weaker: bound.weaker,
492
+ };
493
+ }
494
+ }
495
+ // Maximum timestamp for not-after, same chain-link tie preference.
496
+ for (const bound of segment.upperBounds) {
497
+ for (const h of segment.memberProofHashes) cov.upperCovered.add(h);
498
+ if (
499
+ cov.upperMax === undefined ||
500
+ bound.timestamp > cov.upperMax.timestamp ||
501
+ (bound.timestamp === cov.upperMax.timestamp && cov.upperMax.weaker && !bound.weaker)
502
+ ) {
503
+ cov.upperMax = {
504
+ timestamp: bound.timestamp,
505
+ anchorProofHash: bound.anchorProofHash,
506
+ blockHash: bound.blockHash,
507
+ ...(bound.blockNumber !== undefined ? { blockNumber: bound.blockNumber } : {}),
508
+ evidence: bound.evidence,
509
+ weaker: bound.weaker,
510
+ };
511
+ }
512
+ }
513
+ }
514
+
515
+ // Populate EpochRecord.anchorBounds (the Phase 4c extension point).
516
+ for (const epoch of reconstruction.epochRelationships.epochs) {
517
+ const cov = coverage.get(epoch.epochId) as EpochCoverage;
518
+ const bounds: EpochAnchorBound[] = [];
519
+ if (cov.lowerMin !== undefined) {
520
+ bounds.push({
521
+ kind: "not-before",
522
+ anchorProofHash: cov.lowerMin.anchorProofHash,
523
+ ...(cov.lowerMin.blockNumber !== undefined ? { blockNumber: cov.lowerMin.blockNumber } : {}),
524
+ blockHash: cov.lowerMin.blockHash,
525
+ witnessTimestamp: cov.lowerMin.timestamp,
526
+ coverage: "members-after-anchor",
527
+ coveredProofCount: cov.lowerCovered.size,
528
+ totalProofCount: cov.totalProofCount,
529
+ basis: "block-hash-unpredictability",
530
+ evidence: cov.lowerMin.evidence,
531
+ weaker: cov.lowerMin.weaker,
532
+ claim:
533
+ `${cov.lowerCovered.size} of ${cov.totalProofCount} observed proofs of this epoch were ` +
534
+ `committed no earlier than unix ${cov.lowerMin.timestamp}, grounded in block-hash ` +
535
+ `unpredictability through verified anchor witnesses. This additionally assumes the anchored ` +
536
+ `header is a genuine, publicly published Ethereum block, which this offline audit cannot ` +
537
+ `confirm.` +
538
+ weakerCaveat(cov.lowerMin.weaker) +
539
+ ` The remaining proofs sit causally before the covering anchors and carry no lower bound ` +
540
+ `from this evidence.`,
541
+ });
542
+ }
543
+ if (cov.upperMax !== undefined) {
544
+ bounds.push({
545
+ kind: "not-after",
546
+ anchorProofHash: cov.upperMax.anchorProofHash,
547
+ ...(cov.upperMax.blockNumber !== undefined ? { blockNumber: cov.upperMax.blockNumber } : {}),
548
+ blockHash: cov.upperMax.blockHash,
549
+ witnessTimestamp: cov.upperMax.timestamp,
550
+ coverage: "members-before-anchor",
551
+ coveredProofCount: cov.upperCovered.size,
552
+ totalProofCount: cov.totalProofCount,
553
+ basis: "causal-precedence",
554
+ evidence: cov.upperMax.evidence,
555
+ weaker: cov.upperMax.weaker,
556
+ claim:
557
+ `${cov.upperCovered.size} of ${cov.totalProofCount} observed proofs of this epoch ` +
558
+ `existed before an anchor commit that consumed a block published at unix ` +
559
+ `${cov.upperMax.timestamp}. Reading that timestamp as a wall-clock ceiling additionally ` +
560
+ `assumes the anchor consumed a recently published block; the causal precedence itself ` +
561
+ `is verified.` +
562
+ weakerCaveat(cov.upperMax.weaker) +
563
+ ` Proofs after the covering anchors are not covered.`,
564
+ });
565
+ }
566
+ if (bounds.length > 0) epoch.anchorBounds = bounds;
567
+ }
568
+
569
+ // Covered-portion ordering pairs. Strict inequality: equal timestamps
570
+ // order nothing. Overlaps produce no pair (concurrent-or-unordered),
571
+ // never divergence.
572
+ const pairs: AnchorOrderedPair[] = [];
573
+ const epochs = reconstruction.epochRelationships.epochs;
574
+ for (const before of epochs) {
575
+ const covA = coverage.get(before.epochId) as EpochCoverage;
576
+ if (covA.upperMax === undefined) continue;
577
+ for (const after of epochs) {
578
+ if (after.epochId === before.epochId) continue;
579
+ const covB = coverage.get(after.epochId) as EpochCoverage;
580
+ if (covB.lowerMin === undefined) continue;
581
+ if (covA.upperMax.timestamp >= covB.lowerMin.timestamp) continue;
582
+ const upperEvidence = covA.upperMax.evidence;
583
+ const lowerEvidence = covB.lowerMin.evidence;
584
+ const weaker = covA.upperMax.weaker || covB.lowerMin.weaker;
585
+ pairs.push({
586
+ beforeEpochId: before.epochId,
587
+ afterEpochId: after.epochId,
588
+ upperAnchorProofHash: covA.upperMax.anchorProofHash,
589
+ upperBoundTimestamp: covA.upperMax.timestamp,
590
+ lowerAnchorProofHash: covB.lowerMin.anchorProofHash,
591
+ lowerBoundTimestamp: covB.lowerMin.timestamp,
592
+ basis: "anchor-bounds",
593
+ assumptionDependent: true,
594
+ upperEvidence,
595
+ lowerEvidence,
596
+ weaker,
597
+ beforeCoveredProofCount: covA.upperCovered.size,
598
+ beforeTotalProofCount: covA.totalProofCount,
599
+ afterCoveredProofCount: covB.lowerCovered.size,
600
+ afterTotalProofCount: covB.totalProofCount,
601
+ note:
602
+ `The anchor-covered portion of epoch ${before.epochId} ` +
603
+ `(${covA.upperCovered.size} of ${covA.totalProofCount} proofs) precedes the ` +
604
+ `anchor-covered portion of epoch ${after.epochId} ` +
605
+ `(${covB.lowerCovered.size} of ${covB.totalProofCount} proofs): the first is bounded ` +
606
+ `not-after unix ${covA.upperMax.timestamp} and the second not-before unix ` +
607
+ `${covB.lowerMin.timestamp}. One-sided evidence about the covered portions only; the ` +
608
+ `not-after side rests on the anchor-freshness assumption documented on its bound.` +
609
+ (weaker
610
+ ? ` At least one side rests on commit-counter ordering rather than a verified hash-link ` +
611
+ `path (weaker evidence), which relies on the authority's counter discipline.`
612
+ : "") +
613
+ ` This is ordering evidence, never divergence.`,
614
+ });
615
+ }
616
+ }
617
+ pairs.sort((a, b) =>
618
+ a.beforeEpochId !== b.beforeEpochId
619
+ ? a.beforeEpochId < b.beforeEpochId
620
+ ? -1
621
+ : 1
622
+ : a.afterEpochId < b.afterEpochId
623
+ ? -1
624
+ : a.afterEpochId > b.afterEpochId
625
+ ? 1
626
+ : 0
627
+ );
628
+ return pairs;
629
+ }