@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,642 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+ import { byCounterThenHash, isIntrinsicallyValid, parseCounter, pushMap } from "./validity.js";
3
+ /**
4
+ * Cap on the flat unexplained-position list. Ranges and the total count
5
+ * are always complete; only the flat list truncates, with the truncated
6
+ * flag set.
7
+ */
8
+ const MAX_LISTED_POSITIONS = 10_000;
9
+ // ---------------------------------------------------------------------------
10
+ // Public API
11
+ // ---------------------------------------------------------------------------
12
+ /**
13
+ * Classify anomalies and build divergence records over a reconstructed
14
+ * bundle. Read-only over its inputs. Deterministic: anomalies follow
15
+ * partition order, then a fixed per-partition analysis order (gaps,
16
+ * counter collisions, slot collisions, cross-kind position reuse,
17
+ * predecessor reuse, chain breaks, multiple genesis, slot order), then
18
+ * epoch link anomalies.
19
+ */
20
+ export async function classifyAnomalies(ingest, reconstruction) {
21
+ const anomalies = [];
22
+ const divergences = [];
23
+ const byHash = new Map(ingest.proofs.map((p) => [p.proofHash, p]));
24
+ const partitionOf = new Map();
25
+ for (const partition of reconstruction.partitions) {
26
+ for (const hash of partition.memberProofHashes)
27
+ partitionOf.set(hash, partition.key);
28
+ }
29
+ for (const partition of reconstruction.partitions) {
30
+ const members = partition.memberProofHashes.map((h) => byHash.get(h));
31
+ analyzeGaps(partition, members, anomalies);
32
+ await analyzeCollisions(partition, members, "counter", anomalies, divergences);
33
+ await analyzeCollisions(partition, members, "slot", anomalies, divergences);
34
+ await analyzeCrossKindPositionReuse(partition, members, anomalies, divergences);
35
+ await analyzePredecessorReuse(partition, members, byHash, anomalies, divergences);
36
+ analyzeChainBreaks(partition, members, byHash, partitionOf, anomalies);
37
+ await analyzeMultipleGenesis(partition, members, anomalies, divergences);
38
+ analyzeSlotOrder(partition, members, anomalies);
39
+ }
40
+ await analyzeEpochLinks(reconstruction.epochRelationships.edges, byHash, anomalies, divergences);
41
+ return { anomalies, divergences };
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // G2 gap logic
45
+ // ---------------------------------------------------------------------------
46
+ function analyzeGaps(partition, members, anomalies) {
47
+ // Explained positions: every observed commit counter and every observed
48
+ // slot counter (two positions per proof, never deduplicated against
49
+ // each other).
50
+ const explained = new Set();
51
+ for (const m of members) {
52
+ const commit = parseCounter(m.counter);
53
+ if (commit !== undefined)
54
+ explained.add(commit);
55
+ const slot = parseCounter(m.slotCounter);
56
+ if (slot !== undefined)
57
+ explained.add(slot);
58
+ }
59
+ if (explained.size < 2)
60
+ return; // no interior positions can exist
61
+ const sorted = [...explained].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
62
+ const ranges = [];
63
+ const positions = [];
64
+ let count = 0n;
65
+ for (let i = 1; i < sorted.length; i++) {
66
+ const low = sorted[i - 1] + 1n;
67
+ const high = sorted[i] - 1n;
68
+ if (low > high)
69
+ continue;
70
+ ranges.push({ start: String(low), end: String(high) });
71
+ count += high - low + 1n;
72
+ for (let p = low; p <= high && positions.length < MAX_LISTED_POSITIONS; p++) {
73
+ positions.push(String(p));
74
+ }
75
+ }
76
+ if (ranges.length === 0)
77
+ return;
78
+ const detail = {
79
+ count: String(count),
80
+ ranges,
81
+ positions,
82
+ truncated: count > BigInt(positions.length),
83
+ };
84
+ const min = String(sorted[0]);
85
+ const max = String(sorted[sorted.length - 1]);
86
+ anomalies.push({
87
+ code: "unexplained-counter-positions",
88
+ partition: partition.key,
89
+ proofHashes: [],
90
+ message: `${count} counter position${count === 1n ? "" : "s"} within the observed range [${min}, ${max}] ` +
91
+ `of this partition ${count === 1n ? "is" : "are"} neither a commit position nor a referenced slot position ` +
92
+ `in the supplied bundle. Such ${count === 1n ? "a position" : "positions"} may mean a proof is absent ` +
93
+ `from the bundle, or a slot that was allocated but never committed (a routine, benign occurrence); ` +
94
+ `this offline audit cannot distinguish the two. It does not, by itself, establish that the authority ` +
95
+ `failed to create or withheld any proof.`,
96
+ details: detail,
97
+ });
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Collisions
101
+ // ---------------------------------------------------------------------------
102
+ async function analyzeCollisions(partition, members, kind, anomalies, divergences) {
103
+ const groups = new Map();
104
+ for (const m of members) {
105
+ const raw = kind === "counter" ? m.counter : m.slotCounter;
106
+ if (raw === undefined)
107
+ continue;
108
+ const parsed = parseCounter(raw);
109
+ pushMap(groups, parsed !== undefined ? String(parsed) : `raw:${raw}`, m);
110
+ }
111
+ for (const [groupKey, group] of sortGroupKeys(groups)) {
112
+ if (group.length < 2)
113
+ continue;
114
+ const { parties, context } = await splitByValidity(group);
115
+ // Collisions are defined between valid non-identical proofs. Invalid
116
+ // proofs sharing a position stay on the verification dimension.
117
+ if (parties.length < 2)
118
+ continue;
119
+ const position = groupKey.startsWith("raw:") ? groupKey.slice(4) : groupKey;
120
+ const code = kind === "counter" ? "counter-collision" : "slot-collision";
121
+ anomalies.push({
122
+ code,
123
+ partition: partition.key,
124
+ proofHashes: group.map((m) => m.proofHash),
125
+ message: kind === "counter"
126
+ ? `${parties.length} distinct valid proofs claim commit counter ${position} in one partition. ` +
127
+ `The audit does not choose between them; all parties are preserved for adjudication.`
128
+ : `${parties.length} distinct valid proofs reference slot counter ${position} in one partition. ` +
129
+ `A slot position is consumed exactly once; all parties are preserved for adjudication.`,
130
+ details: {
131
+ position,
132
+ validParties: parties.map((m) => m.proofHash),
133
+ invalidObserved: context.map((m) => m.proofHash),
134
+ },
135
+ });
136
+ divergences.push({
137
+ kind: code,
138
+ partition: partition.key,
139
+ contested: kind === "counter" ? { counter: position } : { slotCounter: position },
140
+ parties: parties.map(toParty),
141
+ invalidContext: context.map(toParty),
142
+ explanation: kind === "counter"
143
+ ? `Two or more independently valid proof objects claim commit counter ${position} in the same ` +
144
+ `signer, epoch, and chain partition. A commit counter position can be consumed only once, so at ` +
145
+ `most one of these can belong to the authoritative sequence. The audit tool does not choose ` +
146
+ `between them; every party and its predecessor relationship is preserved for adjudication.`
147
+ : `Two or more independently valid proof objects reference slot counter ${position} in the same ` +
148
+ `signer, epoch, and chain partition. A slot position is allocated once and consumed by one ` +
149
+ `commit, so at most one of these can belong to the authoritative sequence. The audit tool does ` +
150
+ `not choose between them.`,
151
+ });
152
+ }
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Cross-kind position reuse (one causal position double-allocated)
156
+ // ---------------------------------------------------------------------------
157
+ /**
158
+ * A single causal position is consumed exactly once: reserved as a slot and
159
+ * later consumed by exactly one commit. The same-kind collision checks
160
+ * above catch two commits at one counter or two slots at one counter. This
161
+ * catches the cross-kind case the collision checks miss: one proof's commit
162
+ * counter equal to a DIFFERENT proof's slotCounter in the same partition,
163
+ * a double-allocation of one position that only enclave malfunction,
164
+ * replay, or compromise can produce. A single proof whose own commit and
165
+ * slot share a position is slot-order-violation, handled separately, and is
166
+ * never flagged here. Only intrinsically valid, distinct proofs are
167
+ * declared parties, consistent with the module's other divergences.
168
+ */
169
+ async function analyzeCrossKindPositionReuse(partition, members, anomalies, divergences) {
170
+ const commitAt = new Map();
171
+ const slotAt = new Map();
172
+ for (const m of members) {
173
+ const commit = parseCounter(m.counter);
174
+ if (commit !== undefined)
175
+ pushMap(commitAt, String(commit), m);
176
+ const slot = parseCounter(m.slotCounter);
177
+ if (slot !== undefined)
178
+ pushMap(slotAt, String(slot), m);
179
+ }
180
+ const positions = [...commitAt.keys()]
181
+ .filter((p) => slotAt.has(p))
182
+ .sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0));
183
+ for (const position of positions) {
184
+ const committers = commitAt.get(position);
185
+ const slotters = slotAt.get(position);
186
+ // Divergence parties must be intrinsically valid, as everywhere in this
187
+ // module. Invalid proofs sharing the position stay on the verification
188
+ // dimension and appear only as context.
189
+ const validCommitters = [];
190
+ for (const m of committers)
191
+ if (await isIntrinsicallyValid(m))
192
+ validCommitters.push(m);
193
+ const validSlotters = [];
194
+ for (const m of slotters)
195
+ if (await isIntrinsicallyValid(m))
196
+ validSlotters.push(m);
197
+ // A double-allocation requires a valid commit-side proof and a valid
198
+ // slot-side proof that are DIFFERENT objects. One proof committing and
199
+ // reserving the same position is slot-order-violation, not this.
200
+ const distinctCrossPair = validCommitters.some((c) => validSlotters.some((s) => s.proofHash !== c.proofHash));
201
+ if (!distinctCrossPair)
202
+ continue;
203
+ const partyMap = new Map();
204
+ for (const m of [...validCommitters, ...validSlotters])
205
+ partyMap.set(m.proofHash, m);
206
+ const parties = [...partyMap.values()];
207
+ const involvedMap = new Map();
208
+ for (const m of [...committers, ...slotters])
209
+ involvedMap.set(m.proofHash, m);
210
+ const context = [...involvedMap.values()].filter((m) => !partyMap.has(m.proofHash));
211
+ anomalies.push({
212
+ code: "cross-kind-position-reuse",
213
+ partition: partition.key,
214
+ proofHashes: [...involvedMap.keys()],
215
+ message: `Counter position ${position} is committed by one proof and reserved as a slot by a ` +
216
+ `different proof in the same partition. A single causal position is consumed once, as a ` +
217
+ `slot or as a commit, never both across distinct proofs. The audit does not choose between ` +
218
+ `them; all parties are preserved for adjudication.`,
219
+ details: {
220
+ position,
221
+ commitParties: validCommitters.map((m) => m.proofHash),
222
+ slotParties: validSlotters.map((m) => m.proofHash),
223
+ validParties: parties.map((m) => m.proofHash),
224
+ invalidObserved: context.map((m) => m.proofHash),
225
+ },
226
+ });
227
+ divergences.push({
228
+ kind: "cross-kind-position-reuse",
229
+ partition: partition.key,
230
+ contested: { position },
231
+ parties: parties.map(toParty),
232
+ invalidContext: context.map(toParty),
233
+ explanation: `Two or more independently valid proof objects allocate counter position ${position} in the ` +
234
+ `same signer, epoch, and chain partition through different roles: at least one commits at it ` +
235
+ `and at least one reserves it as a slot. Each position in a chain is consumed exactly once, so ` +
236
+ `at most one of these allocations can belong to the authoritative sequence. This pattern only ` +
237
+ `arises through enclave malfunction, replay, or compromise. The audit tool does not choose ` +
238
+ `between them; every party is preserved for adjudication.`,
239
+ });
240
+ }
241
+ }
242
+ // ---------------------------------------------------------------------------
243
+ // Predecessor reuse (forks)
244
+ // ---------------------------------------------------------------------------
245
+ async function analyzePredecessorReuse(partition, members, byHash, anomalies, divergences) {
246
+ const byPrev = new Map();
247
+ for (const m of members) {
248
+ if (m.prevB64 === undefined)
249
+ continue;
250
+ pushMap(byPrev, m.prevB64, m);
251
+ }
252
+ for (const [prevB64, claimants] of sortGroupKeys(byPrev)) {
253
+ if (claimants.length < 2)
254
+ continue;
255
+ const { parties, context } = await splitByValidity(claimants);
256
+ if (parties.length < 2)
257
+ continue;
258
+ const predecessorObserved = byHash.has(prevB64);
259
+ anomalies.push({
260
+ code: "predecessor-reuse",
261
+ partition: partition.key,
262
+ proofHashes: claimants.map((m) => m.proofHash),
263
+ message: `${parties.length} distinct valid proofs claim the same predecessor hash: a detectable fork. ` +
264
+ `All branches are preserved; the audit does not choose between them.`,
265
+ details: {
266
+ prevB64,
267
+ predecessorObserved,
268
+ validParties: parties.map((m) => m.proofHash),
269
+ invalidObserved: context.map((m) => m.proofHash),
270
+ },
271
+ });
272
+ divergences.push({
273
+ kind: "predecessor-reuse",
274
+ partition: partition.key,
275
+ contested: { prevB64 },
276
+ parties: parties.map(toParty),
277
+ invalidContext: context.map(toParty),
278
+ explanation: `Two or more independently valid proof objects name the same predecessor proof in their prevB64 ` +
279
+ `hash link. Each proof has exactly one successor in an intact chain, so this is a fork: the ` +
280
+ `history diverges into multiple branches at that predecessor` +
281
+ `${predecessorObserved ? ", which is itself observed in the bundle" : ", which is absent from the bundle"}. ` +
282
+ `All branches are shown; the audit tool does not select one.`,
283
+ });
284
+ }
285
+ }
286
+ // ---------------------------------------------------------------------------
287
+ // Chain breaks
288
+ // ---------------------------------------------------------------------------
289
+ function analyzeChainBreaks(partition, members, byHash, partitionOf, anomalies) {
290
+ const memberHashes = new Set(members.map((m) => m.proofHash));
291
+ for (const m of [...members].sort(byCounterThenHash)) {
292
+ if (m.prevB64 === undefined)
293
+ continue;
294
+ if (memberHashes.has(m.prevB64))
295
+ continue; // resolved in-partition
296
+ if (!isStrict32ByteBase64(m.prevB64)) {
297
+ anomalies.push({
298
+ code: "chain-break-malformed",
299
+ partition: partition.key,
300
+ proofHashes: [m.proofHash],
301
+ message: "commit.prevB64 is not standard base64 of 32 bytes, so it can never match a canonical proof " +
302
+ "hash. The predecessor link is unusable as reconstruction evidence.",
303
+ details: { prevB64: m.prevB64 },
304
+ });
305
+ continue;
306
+ }
307
+ const elsewhere = byHash.get(m.prevB64);
308
+ if (elsewhere !== undefined) {
309
+ const otherKey = partitionOf.get(elsewhere.proofHash);
310
+ anomalies.push({
311
+ code: "chain-break-cross-partition",
312
+ partition: partition.key,
313
+ proofHashes: [m.proofHash, elsewhere.proofHash],
314
+ message: "commit.prevB64 resolves to an observed proof outside this proof's own signer, epoch, and " +
315
+ "chain partition. prevB64 never bridges partitions (each epoch and chain keeps its own hash " +
316
+ "chain), so this link does not join the two.",
317
+ details: {
318
+ prevB64: m.prevB64,
319
+ predecessorProofHash: elsewhere.proofHash,
320
+ predecessorPartition: otherKey !== undefined
321
+ ? {
322
+ publicKeyB64: otherKey.publicKeyB64,
323
+ ...(otherKey.epochId !== undefined ? { epochId: otherKey.epochId } : {}),
324
+ chainId: otherKey.chainId,
325
+ }
326
+ : "unchained-or-unpartitioned",
327
+ },
328
+ });
329
+ continue;
330
+ }
331
+ anomalies.push({
332
+ code: "chain-break-missing",
333
+ partition: partition.key,
334
+ proofHashes: [m.proofHash],
335
+ message: "commit.prevB64 references a predecessor proof that is absent from the supplied bundle. The " +
336
+ "chain cannot be reconstructed across this link from the supplied evidence; this does not, by " +
337
+ "itself, establish that the predecessor never existed.",
338
+ details: { prevB64: m.prevB64 },
339
+ });
340
+ }
341
+ }
342
+ function isStrict32ByteBase64(value) {
343
+ if (value.length === 0)
344
+ return false;
345
+ const decoded = Buffer.from(value, "base64");
346
+ return decoded.length === 32 && decoded.toString("base64") === value;
347
+ }
348
+ // ---------------------------------------------------------------------------
349
+ // Multiple genesis
350
+ // ---------------------------------------------------------------------------
351
+ async function analyzeMultipleGenesis(partition, members, anomalies, divergences) {
352
+ // A single proof without prevB64 is the epoch genesis: normal per G1,
353
+ // never an anomaly.
354
+ const genesis = [...members.filter((m) => m.prevB64 === undefined)].sort(byCounterThenHash);
355
+ if (genesis.length < 2)
356
+ return;
357
+ const { parties, context } = await splitByValidity(genesis);
358
+ anomalies.push({
359
+ code: "multiple-genesis",
360
+ partition: partition.key,
361
+ proofHashes: genesis.map((m) => m.proofHash),
362
+ message: `${genesis.length} proofs in one partition carry no prevB64 link. An epoch has exactly one first ` +
363
+ `proof, so at most one of these can be the genesis of this partition's chain.`,
364
+ details: {
365
+ validParties: parties.map((m) => m.proofHash),
366
+ invalidObserved: context.map((m) => m.proofHash),
367
+ },
368
+ });
369
+ if (parties.length < 2)
370
+ return;
371
+ divergences.push({
372
+ kind: "multiple-genesis",
373
+ partition: partition.key,
374
+ contested: {
375
+ ...(partition.key.epochId !== undefined ? { epochId: partition.key.epochId } : {}),
376
+ chainId: partition.key.chainId,
377
+ },
378
+ parties: parties.map(toParty),
379
+ invalidContext: context.map(toParty),
380
+ explanation: "Two or more independently valid proof objects in the same signer, epoch, and chain partition " +
381
+ "carry no predecessor link, each presenting itself as the first proof of the chain. An epoch has " +
382
+ "exactly one first proof, so at most one of these can be the genesis. The audit tool does not " +
383
+ "choose between them.",
384
+ });
385
+ }
386
+ // ---------------------------------------------------------------------------
387
+ // Slot ordering
388
+ // ---------------------------------------------------------------------------
389
+ function analyzeSlotOrder(partition, members, anomalies) {
390
+ for (const m of [...members].sort(byCounterThenHash)) {
391
+ const slot = parseCounter(m.slotCounter);
392
+ const commit = parseCounter(m.counter);
393
+ if (slot === undefined || commit === undefined)
394
+ continue;
395
+ if (slot < commit)
396
+ continue;
397
+ anomalies.push({
398
+ code: "slot-order-violation",
399
+ partition: partition.key,
400
+ proofHashes: [m.proofHash],
401
+ message: "commit.slotCounter is not strictly less than commit.counter. The slot must be allocated " +
402
+ "before the commit that consumes it, so this proof's declared positions contradict the " +
403
+ "nonce-first construction order.",
404
+ details: {
405
+ slotCounter: m.slotCounter,
406
+ counter: m.counter,
407
+ },
408
+ });
409
+ }
410
+ }
411
+ // ---------------------------------------------------------------------------
412
+ // Epoch link anomalies
413
+ // ---------------------------------------------------------------------------
414
+ async function analyzeEpochLinks(edges, byHash, anomalies, divergences) {
415
+ // Per-edge resolutions, in edge (observation) order.
416
+ for (const edge of edges) {
417
+ if (edge.resolution === "terminal-missing") {
418
+ anomalies.push({
419
+ code: "epochlink-terminal-missing",
420
+ proofHashes: [edge.viaProofHash],
421
+ message: "commit.epochLink references a prior epoch that is observed in the bundle, but the referenced " +
422
+ "terminal proof is absent from the bundle. The lineage claim cannot be confirmed from the " +
423
+ "supplied evidence; this does not, by itself, establish that the terminal never existed.",
424
+ details: { link: { ...edge.link } },
425
+ });
426
+ }
427
+ else if (edge.resolution === "dangling") {
428
+ anomalies.push({
429
+ code: "epochlink-dangling",
430
+ proofHashes: [edge.viaProofHash],
431
+ message: "commit.epochLink references a prior epoch and terminal proof, neither of which is observed " +
432
+ "in the bundle. The lineage claim dangles into unobserved history; it cannot be confirmed from " +
433
+ "the supplied evidence.",
434
+ details: { link: { ...edge.link } },
435
+ });
436
+ }
437
+ else if (edge.metadataConsistent === false) {
438
+ anomalies.push({
439
+ code: "epochlink-mismatch",
440
+ proofHashes: [
441
+ edge.viaProofHash,
442
+ ...(edge.predecessorProofHash !== undefined ? [edge.predecessorProofHash] : []),
443
+ ],
444
+ message: "commit.epochLink's prevProofHashB64 matches an observed proof, but the link's declared " +
445
+ "epoch, signer key, or counter disagrees with that proof. The hash reference and the declared " +
446
+ "metadata cannot both be right.",
447
+ details: {
448
+ link: { ...edge.link },
449
+ inconsistencies: edge.inconsistencies ?? [],
450
+ },
451
+ });
452
+ }
453
+ }
454
+ // Forks: one predecessor tuple consumed by two or more distinct
455
+ // successor epochs (the canonical verifier's single-successor
456
+ // invariant, applied to the observed record).
457
+ const byTuple = new Map();
458
+ for (const edge of edges) {
459
+ pushMap(byTuple, `${edge.link.prevEpochId}|${edge.link.prevCounter}|${edge.link.prevProofHashB64}`, edge);
460
+ }
461
+ for (const [, group] of sortGroupKeys(byTuple)) {
462
+ const successorEpochs = new Set(group.map((e) => e.link.toEpochId));
463
+ if (successorEpochs.size < 2)
464
+ continue;
465
+ const viaProofs = group.map((e) => byHash.get(e.viaProofHash));
466
+ const { parties, context } = await splitByValidity(viaProofs);
467
+ const sample = group[0].link;
468
+ anomalies.push({
469
+ code: "epochlink-fork",
470
+ proofHashes: group.map((e) => e.viaProofHash),
471
+ message: `The same predecessor terminal (epoch ${sample.prevEpochId}, counter ${sample.prevCounter}) is ` +
472
+ `consumed by genesis proofs of ${successorEpochs.size} distinct successor epochs. A predecessor ` +
473
+ `may be consumed by at most one successor epoch, so this is a detectable fork at the epoch ` +
474
+ `boundary. All branches are preserved.`,
475
+ details: {
476
+ prevEpochId: sample.prevEpochId,
477
+ prevCounter: sample.prevCounter,
478
+ prevProofHashB64: sample.prevProofHashB64,
479
+ successorEpochIds: [...successorEpochs].sort(),
480
+ validParties: parties.map((m) => m.proofHash),
481
+ invalidObserved: context.map((m) => m.proofHash),
482
+ },
483
+ });
484
+ if (parties.length < 2)
485
+ continue;
486
+ divergences.push({
487
+ kind: "epochlink-fork",
488
+ contested: {
489
+ prevEpochId: sample.prevEpochId,
490
+ prevCounter: sample.prevCounter,
491
+ prevProofHashB64: sample.prevProofHashB64,
492
+ },
493
+ parties: parties.map(toParty),
494
+ invalidContext: context.map(toParty),
495
+ explanation: "Two or more independently valid epoch-genesis proofs consume the same terminal proof of a " +
496
+ "prior epoch through their epochLink. The single-successor rule allows a terminal to be " +
497
+ "consumed by exactly one successor epoch, so at most one of these lineages can be the " +
498
+ "authoritative continuation. Note that whichever party verified second in this run carries a " +
499
+ "fork-detection failure from the canonical verifier; that ordering is an artifact of " +
500
+ "verification order, not evidence of which branch is authoritative. The audit tool does not " +
501
+ "choose between them.",
502
+ });
503
+ }
504
+ // Cycles: the claimed lineage graph must be acyclic; a cycle means the
505
+ // epochs' ordering claims contradict each other. Detected over all
506
+ // matched edges regardless of validity, because the contradiction is a
507
+ // property of the observed claims.
508
+ const adjacency = new Map();
509
+ for (const edge of edges) {
510
+ if (edge.resolution !== "matched")
511
+ continue;
512
+ const set = adjacency.get(edge.fromEpochId);
513
+ if (set === undefined)
514
+ adjacency.set(edge.fromEpochId, new Set([edge.toEpochId]));
515
+ else
516
+ set.add(edge.toEpochId);
517
+ }
518
+ for (const cycle of findCycles(adjacency)) {
519
+ const involved = edges
520
+ .filter((e) => e.resolution === "matched" &&
521
+ cycle.includes(e.fromEpochId) &&
522
+ cycle.includes(e.toEpochId))
523
+ .map((e) => e.viaProofHash);
524
+ anomalies.push({
525
+ code: "epochlink-cycle",
526
+ proofHashes: involved,
527
+ message: `Epoch lineage links form a cycle among ${cycle.length} epochs. Each link claims its successor ` +
528
+ `epoch came after its predecessor, so a cycle means these ordering claims contradict each ` +
529
+ `other; no ordering is asserted for the epochs involved.`,
530
+ details: { epochIds: cycle },
531
+ });
532
+ }
533
+ }
534
+ /**
535
+ * Find simple cycles in the epoch lineage graph via iterative DFS with
536
+ * an explicit path stack. Returns each cycle once, rotated so the
537
+ * lexicographically smallest epochId leads, sorted deterministically.
538
+ */
539
+ function findCycles(adjacency) {
540
+ const cycles = new Map();
541
+ const visited = new Set();
542
+ const nodes = [...adjacency.keys()].sort();
543
+ for (const start of nodes) {
544
+ if (visited.has(start))
545
+ continue;
546
+ // Iterative DFS tracking the current path.
547
+ const path = [];
548
+ const onPath = new Set();
549
+ const iterators = new Map();
550
+ const stack = [start];
551
+ while (stack.length > 0) {
552
+ const node = stack[stack.length - 1];
553
+ if (!onPath.has(node)) {
554
+ onPath.add(node);
555
+ path.push(node);
556
+ iterators.set(node, [...(adjacency.get(node) ?? [])].sort());
557
+ }
558
+ const pending = iterators.get(node);
559
+ const next = pending.shift();
560
+ if (next === undefined) {
561
+ stack.pop();
562
+ onPath.delete(node);
563
+ path.pop();
564
+ visited.add(node);
565
+ continue;
566
+ }
567
+ if (onPath.has(next)) {
568
+ // Back edge: extract the cycle from the path.
569
+ const fromIndex = path.indexOf(next);
570
+ const cycle = path.slice(fromIndex);
571
+ const canonical = canonicalizeCycle(cycle);
572
+ cycles.set(canonical.join("|"), canonical);
573
+ continue;
574
+ }
575
+ if (!visited.has(next))
576
+ stack.push(next);
577
+ }
578
+ }
579
+ return [...cycles.values()].sort((a, b) => {
580
+ const ja = a.join("|");
581
+ const jb = b.join("|");
582
+ return ja < jb ? -1 : ja > jb ? 1 : 0;
583
+ });
584
+ }
585
+ function canonicalizeCycle(cycle) {
586
+ let minIndex = 0;
587
+ for (let i = 1; i < cycle.length; i++) {
588
+ if (cycle[i] < cycle[minIndex])
589
+ minIndex = i;
590
+ }
591
+ return [...cycle.slice(minIndex), ...cycle.slice(0, minIndex)];
592
+ }
593
+ // ---------------------------------------------------------------------------
594
+ // Shared helpers
595
+ // ---------------------------------------------------------------------------
596
+ async function splitByValidity(group) {
597
+ const parties = [];
598
+ const context = [];
599
+ for (const m of group) {
600
+ if (await isIntrinsicallyValid(m))
601
+ parties.push(m);
602
+ else
603
+ context.push(m);
604
+ }
605
+ return { parties, context };
606
+ }
607
+ function toParty(m) {
608
+ const verification = m.verification;
609
+ return {
610
+ proofHash: m.proofHash,
611
+ sourcePaths: m.sources.map((s) => s.path),
612
+ ...(verification !== undefined
613
+ ? {
614
+ verificationTier: verification.tier,
615
+ verificationStatus: verification.status,
616
+ ...(verification.reason !== undefined ? { verificationReason: verification.reason } : {}),
617
+ }
618
+ : {}),
619
+ ...(m.counter !== undefined ? { counter: m.counter } : {}),
620
+ ...(m.slotCounter !== undefined ? { slotCounter: m.slotCounter } : {}),
621
+ ...(m.prevB64 !== undefined ? { prevB64: m.prevB64 } : {}),
622
+ ...(m.publicKeyB64 !== undefined ? { publicKeyB64: m.publicKeyB64 } : {}),
623
+ ...(m.epochId !== undefined ? { epochId: m.epochId } : {}),
624
+ chainId: m.chainId,
625
+ ...(m.measurement !== undefined ? { measurement: m.measurement } : {}),
626
+ };
627
+ }
628
+ /** Deterministic group iteration: numeric keys ascending, then raw keys. */
629
+ function sortGroupKeys(groups) {
630
+ return [...groups.entries()].sort(([a], [b]) => {
631
+ const na = /^[0-9]+$/.test(a) ? BigInt(a) : undefined;
632
+ const nb = /^[0-9]+$/.test(b) ? BigInt(b) : undefined;
633
+ if (na !== undefined && nb !== undefined && na !== nb)
634
+ return na < nb ? -1 : 1;
635
+ if (na !== undefined && nb === undefined)
636
+ return -1;
637
+ if (na === undefined && nb !== undefined)
638
+ return 1;
639
+ return a < b ? -1 : a > b ? 1 : 0;
640
+ });
641
+ }
642
+ //# sourceMappingURL=anomalies.js.map