@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,491 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * bitgraph-audit causal reconstruction
5
+ *
6
+ * Rebuilds the observed causal record from an ingested bundle:
7
+ *
8
+ * 1. Partition observed proofs by (signer publicKeyB64, epochId,
9
+ * chainId) per G6. Multiple signer lineages are never merged.
10
+ * Chainless proofs (neither counter nor epochId) stay outside
11
+ * partitions as observed-but-unchained; proofs missing a signer key
12
+ * cannot join a lineage and are listed as unpartitioned.
13
+ * 2. Within each partition, reconstruct chain components from prevB64
14
+ * hash links as the PRIMARY evidence: an edge exists exactly when a
15
+ * proof's prevB64 equals the canonical proof hash of another member
16
+ * (the hash computeProofHash returns over the full canonical signed
17
+ * body, which is what the enclave writes into prevB64). Counters are
18
+ * ordering and anomaly evidence, never the reconstruction mechanism.
19
+ * 3. Derive epoch relationships per G1: epochs are independent chains;
20
+ * cross-epoch ordering comes ONLY from verified epochLink lineage
21
+ * (hard edges), with a typed extension point for anchor-derived
22
+ * one-sided bounds (populated by the anchor analysis stage). Epochs
23
+ * with neither are observed-but-unordered, which is never divergence.
24
+ *
25
+ * Verification status and chain topology stay separate dimensions:
26
+ * reconstruction includes verifier-failed proofs as observed members and
27
+ * never reinterprets a verification failure as a chain anomaly (or the
28
+ * reverse). Validity enters exactly once, and conservatively: an epoch
29
+ * lineage edge counts as hard ordering evidence only when both endpoint
30
+ * proofs are intrinsically valid.
31
+ *
32
+ * Run this after verifyObservedProofs. Isolated validity rechecks reset
33
+ * the verify package's module-level epoch link state (see validity.ts).
34
+ */
35
+
36
+ import type {
37
+ ChainComponent,
38
+ ChainPartition,
39
+ EpochLineageEdge,
40
+ EpochLinkFields,
41
+ EpochRecord,
42
+ EpochRelationshipResult,
43
+ IngestResult,
44
+ ObservedProof,
45
+ PartitionKey,
46
+ ReconstructionResult,
47
+ } from "./types.js";
48
+ import { byCounterThenHash, isIntrinsicallyValid, parseCounter, pushMap } from "./validity.js";
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Public API
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Reconstruct partitions, chain components, and epoch relationships from
56
+ * an ingested bundle. Read-only over the ingest result. Deterministic:
57
+ * the same bundle always produces the same structures in the same order.
58
+ */
59
+ export async function reconstructChains(ingest: IngestResult): Promise<ReconstructionResult> {
60
+ // -----------------------------------------------------------------------
61
+ // 1. Partition per G6.
62
+ // -----------------------------------------------------------------------
63
+ const unchained: string[] = [];
64
+ const unpartitioned: string[] = [];
65
+ const partitionsByKey = new Map<string, { key: PartitionKey; members: ObservedProof[] }>();
66
+
67
+ for (const proof of ingest.proofs) {
68
+ if (proof.chainless) {
69
+ unchained.push(proof.proofHash);
70
+ continue;
71
+ }
72
+ if (proof.publicKeyB64 === undefined) {
73
+ unpartitioned.push(proof.proofHash);
74
+ continue;
75
+ }
76
+ const key: PartitionKey = {
77
+ publicKeyB64: proof.publicKeyB64,
78
+ ...(proof.epochId !== undefined ? { epochId: proof.epochId } : {}),
79
+ chainId: proof.chainId,
80
+ };
81
+ const mapKey = JSON.stringify([key.publicKeyB64, key.epochId ?? null, key.chainId]);
82
+ const existing = partitionsByKey.get(mapKey);
83
+ if (existing === undefined) partitionsByKey.set(mapKey, { key, members: [proof] });
84
+ else existing.members.push(proof);
85
+ }
86
+
87
+ const sortedEntries = [...partitionsByKey.values()].sort((a, b) =>
88
+ comparePartitionKeys(a.key, b.key)
89
+ );
90
+
91
+ // -----------------------------------------------------------------------
92
+ // 2. Reconstruct components per partition from hash links.
93
+ // -----------------------------------------------------------------------
94
+ const partitions: ChainPartition[] = [];
95
+ /** Member hashes that have an in-partition successor (for terminal checks). */
96
+ const hasInPartitionSuccessor = new Set<string>();
97
+ /** proofHash -> partition map key, for cross-partition lookups. */
98
+ const partitionOf = new Map<string, PartitionKey>();
99
+
100
+ for (const entry of sortedEntries) {
101
+ const partition = buildPartition(entry.key, entry.members, hasInPartitionSuccessor);
102
+ partitions.push(partition);
103
+ for (const hash of partition.memberProofHashes) partitionOf.set(hash, entry.key);
104
+ }
105
+
106
+ // -----------------------------------------------------------------------
107
+ // 3. Epoch relationships per G1.
108
+ // -----------------------------------------------------------------------
109
+ const epochRelationships = await buildEpochRelationships(
110
+ ingest,
111
+ partitions,
112
+ hasInPartitionSuccessor,
113
+ partitionOf
114
+ );
115
+
116
+ return {
117
+ partitions,
118
+ unchainedProofHashes: unchained,
119
+ unpartitionedProofHashes: unpartitioned,
120
+ epochRelationships,
121
+ };
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Partition and component construction
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function comparePartitionKeys(a: PartitionKey, b: PartitionKey): number {
129
+ if (a.chainId !== b.chainId) return a.chainId < b.chainId ? -1 : 1;
130
+ const ea = a.epochId ?? "";
131
+ const eb = b.epochId ?? "";
132
+ if (ea !== eb) return ea < eb ? -1 : 1;
133
+ if (a.publicKeyB64 !== b.publicKeyB64) return a.publicKeyB64 < b.publicKeyB64 ? -1 : 1;
134
+ return 0;
135
+ }
136
+
137
+ function buildPartition(
138
+ key: PartitionKey,
139
+ members: ObservedProof[],
140
+ hasInPartitionSuccessor: Set<string>
141
+ ): ChainPartition {
142
+ const byHash = new Map<string, ObservedProof>(members.map((m) => [m.proofHash, m]));
143
+
144
+ // Hash-link edges: successor's prevB64 equals a member's canonical hash.
145
+ const successors = new Map<string, ObservedProof[]>();
146
+ const resolvedPrev = new Set<string>(); // member hashes whose prevB64 resolved in-partition
147
+
148
+ // Union-find over member hashes.
149
+ const parent = new Map<string, string>();
150
+ for (const m of members) parent.set(m.proofHash, m.proofHash);
151
+ const find = (x: string): string => {
152
+ let root = x;
153
+ while ((parent.get(root) as string) !== root) root = parent.get(root) as string;
154
+ // Path compression.
155
+ let cursor = x;
156
+ while (cursor !== root) {
157
+ const next = parent.get(cursor) as string;
158
+ parent.set(cursor, root);
159
+ cursor = next;
160
+ }
161
+ return root;
162
+ };
163
+ const union = (a: string, b: string): void => {
164
+ const ra = find(a);
165
+ const rb = find(b);
166
+ if (ra !== rb) parent.set(ra, rb);
167
+ };
168
+
169
+ for (const m of members) {
170
+ if (m.prevB64 === undefined) continue;
171
+ const pred = byHash.get(m.prevB64);
172
+ if (pred === undefined || pred.proofHash === m.proofHash) continue;
173
+ resolvedPrev.add(m.proofHash);
174
+ pushMap(successors, pred.proofHash, m);
175
+ hasInPartitionSuccessor.add(pred.proofHash);
176
+ union(pred.proofHash, m.proofHash);
177
+ }
178
+
179
+ // Group members into connected components.
180
+ const membersByRoot = new Map<string, ObservedProof[]>();
181
+ for (const m of members) pushMap(membersByRoot, find(m.proofHash), m);
182
+
183
+ const components: ChainComponent[] = [];
184
+ for (const componentMembers of membersByRoot.values()) {
185
+ components.push(buildComponent(componentMembers, successors, resolvedPrev));
186
+ }
187
+ components.sort(compareComponents);
188
+
189
+ const memberProofHashes = [...members].sort(byCounterThenHash).map((m) => m.proofHash);
190
+ return { key, memberProofHashes, components };
191
+ }
192
+
193
+ function buildComponent(
194
+ componentMembers: ObservedProof[],
195
+ successors: Map<string, ObservedProof[]>,
196
+ resolvedPrev: Set<string>
197
+ ): ChainComponent {
198
+ const sorted = [...componentMembers].sort(byCounterThenHash);
199
+ const genesis = sorted.filter((m) => m.prevB64 === undefined);
200
+ const broken = sorted.filter((m) => m.prevB64 !== undefined && !resolvedPrev.has(m.proofHash));
201
+ const terminals = sorted.filter((m) => (successors.get(m.proofHash) ?? []).length === 0);
202
+
203
+ // Link-order traversal: deterministic iterative DFS from genesis and
204
+ // broken-link entry points, following successor hash links, branches
205
+ // ordered by counter then hash. Iterative so long chains never hit
206
+ // recursion limits.
207
+ const visited = new Set<string>();
208
+ const order: string[] = [];
209
+ const visitFrom = (startHash: string): void => {
210
+ const stack: string[] = [startHash];
211
+ while (stack.length > 0) {
212
+ const hash = stack.pop() as string;
213
+ if (visited.has(hash)) continue;
214
+ visited.add(hash);
215
+ order.push(hash);
216
+ const next = [...(successors.get(hash) ?? [])].sort(byCounterThenHash);
217
+ for (let i = next.length - 1; i >= 0; i--) stack.push((next[i] as ObservedProof).proofHash);
218
+ }
219
+ };
220
+ for (const start of [...genesis, ...broken]) visitFrom(start.proofHash);
221
+ // Defensive completeness: prevB64 links cannot form cycles among
222
+ // observed members (a cycle needs a proof whose canonical SHA-256 hash
223
+ // appears inside its own signed body, a hash fixpoint), so every member
224
+ // is reachable from an entry point. Cover any leftovers anyway.
225
+ for (const m of sorted) {
226
+ if (!visited.has(m.proofHash)) visitFrom(m.proofHash);
227
+ }
228
+
229
+ // Counter evidence over both position kinds (G2: a proof spans two
230
+ // positions, its slotCounter and its commit counter).
231
+ let min: bigint | undefined;
232
+ let max: bigint | undefined;
233
+ for (const m of componentMembers) {
234
+ for (const value of [m.counter, m.slotCounter]) {
235
+ const n = parseCounter(value);
236
+ if (n === undefined) continue;
237
+ if (min === undefined || n < min) min = n;
238
+ if (max === undefined || n > max) max = n;
239
+ }
240
+ }
241
+
242
+ return {
243
+ memberProofHashes: order,
244
+ genesisProofHashes: genesis.map((m) => m.proofHash),
245
+ brokenLinkProofHashes: broken.map((m) => m.proofHash),
246
+ terminalProofHashes: terminals.map((m) => m.proofHash),
247
+ hasCounterEvidence: min !== undefined,
248
+ ...(min !== undefined && max !== undefined
249
+ ? { positionRange: { min: String(min), max: String(max) } }
250
+ : {}),
251
+ };
252
+ }
253
+
254
+ function compareComponents(a: ChainComponent, b: ChainComponent): number {
255
+ const ma = a.positionRange?.min;
256
+ const mb = b.positionRange?.min;
257
+ if (ma !== undefined && mb !== undefined && ma !== mb) {
258
+ return BigInt(ma) < BigInt(mb) ? -1 : 1;
259
+ }
260
+ if (ma !== undefined && mb === undefined) return -1;
261
+ if (ma === undefined && mb !== undefined) return 1;
262
+ const ha = a.memberProofHashes[0] ?? "";
263
+ const hb = b.memberProofHashes[0] ?? "";
264
+ return ha < hb ? -1 : ha > hb ? 1 : 0;
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Epoch relationships (G1)
269
+ // ---------------------------------------------------------------------------
270
+
271
+ async function buildEpochRelationships(
272
+ ingest: IngestResult,
273
+ partitions: ChainPartition[],
274
+ hasInPartitionSuccessor: Set<string>,
275
+ partitionOf: Map<string, PartitionKey>
276
+ ): Promise<EpochRelationshipResult> {
277
+ // Epoch inventory from partitioned proofs.
278
+ const epochAccum = new Map<
279
+ string,
280
+ { chainIds: Set<string>; publicKeys: Set<string>; proofCount: number }
281
+ >();
282
+ for (const partition of partitions) {
283
+ const epochId = partition.key.epochId;
284
+ if (epochId === undefined) continue;
285
+ let accum = epochAccum.get(epochId);
286
+ if (accum === undefined) {
287
+ accum = { chainIds: new Set(), publicKeys: new Set(), proofCount: 0 };
288
+ epochAccum.set(epochId, accum);
289
+ }
290
+ accum.chainIds.add(partition.key.chainId);
291
+ accum.publicKeys.add(partition.key.publicKeyB64);
292
+ accum.proofCount += partition.memberProofHashes.length;
293
+ }
294
+
295
+ const byHash = new Map<string, ObservedProof>(ingest.proofs.map((p) => [p.proofHash, p]));
296
+
297
+ // Analyze every observed epochLink, in observation order.
298
+ const edges: EpochLineageEdge[] = [];
299
+ for (const proof of ingest.proofs) {
300
+ if (!proof.hasEpochLink) continue;
301
+ const link = extractEpochLink(proof);
302
+ if (link === null) continue; // malformed shape; the verification dimension reports it
303
+ edges.push(
304
+ await analyzeEdge(link, proof, byHash, epochAccum, hasInPartitionSuccessor, partitionOf)
305
+ );
306
+ }
307
+
308
+ // Transitive ordering over hard edges only.
309
+ const orderedPairs = deriveOrderedPairs(edges);
310
+
311
+ // Epoch records.
312
+ const linkedEpochs = new Set<string>();
313
+ const directPredecessors = new Map<string, Set<string>>();
314
+ const directSuccessors = new Map<string, Set<string>>();
315
+ for (const edge of edges) {
316
+ if (!edge.hardEdge) continue;
317
+ linkedEpochs.add(edge.fromEpochId);
318
+ linkedEpochs.add(edge.toEpochId);
319
+ addToSetMap(directPredecessors, edge.toEpochId, edge.fromEpochId);
320
+ addToSetMap(directSuccessors, edge.fromEpochId, edge.toEpochId);
321
+ }
322
+
323
+ const epochs: EpochRecord[] = [...epochAccum.entries()]
324
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
325
+ .map(([epochId, accum]) => ({
326
+ epochId,
327
+ chainIds: [...accum.chainIds].sort(),
328
+ publicKeysB64: [...accum.publicKeys].sort(),
329
+ proofCount: accum.proofCount,
330
+ ordering: linkedEpochs.has(epochId) ? ("linked" as const) : ("observed-but-unordered" as const),
331
+ linkedPredecessorEpochIds: [...(directPredecessors.get(epochId) ?? [])].sort(),
332
+ linkedSuccessorEpochIds: [...(directSuccessors.get(epochId) ?? [])].sort(),
333
+ }));
334
+
335
+ return { epochs, edges, orderedPairs };
336
+ }
337
+
338
+ /** Extract the six declared epochLink fields; null when any is not a string. */
339
+ function extractEpochLink(proof: ObservedProof): EpochLinkFields | null {
340
+ const commit = (proof.proof as unknown as Record<string, unknown>)["commit"];
341
+ if (commit === null || typeof commit !== "object") return null;
342
+ const raw = (commit as Record<string, unknown>)["epochLink"];
343
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
344
+ const link = raw as Record<string, unknown>;
345
+ const fields = [
346
+ "prevEpochId",
347
+ "prevPublicKeyB64",
348
+ "prevCounter",
349
+ "prevProofHashB64",
350
+ "toEpochId",
351
+ "toPublicKeyB64",
352
+ ] as const;
353
+ const out: Record<string, string> = {};
354
+ for (const field of fields) {
355
+ const value = link[field];
356
+ if (typeof value !== "string" || value.length === 0) return null;
357
+ out[field] = value;
358
+ }
359
+ return out as unknown as EpochLinkFields;
360
+ }
361
+
362
+ async function analyzeEdge(
363
+ link: EpochLinkFields,
364
+ viaProof: ObservedProof,
365
+ byHash: Map<string, ObservedProof>,
366
+ epochAccum: Map<string, { chainIds: Set<string>; publicKeys: Set<string>; proofCount: number }>,
367
+ hasInPartitionSuccessor: Set<string>,
368
+ partitionOf: Map<string, PartitionKey>
369
+ ): Promise<EpochLineageEdge> {
370
+ const viaProofValid = await isIntrinsicallyValid(viaProof);
371
+ const predecessor = byHash.get(link.prevProofHashB64);
372
+
373
+ if (predecessor === undefined) {
374
+ const priorEpochObserved = epochAccum.has(link.prevEpochId);
375
+ return {
376
+ fromEpochId: link.prevEpochId,
377
+ toEpochId: link.toEpochId,
378
+ viaProofHash: viaProof.proofHash,
379
+ link,
380
+ resolution: priorEpochObserved ? "terminal-missing" : "dangling",
381
+ viaProofValid,
382
+ hardEdge: false,
383
+ };
384
+ }
385
+
386
+ const inconsistencies: string[] = [];
387
+ if (predecessor.epochId !== link.prevEpochId) {
388
+ inconsistencies.push(
389
+ `the referenced proof's epochId (${predecessor.epochId ?? "absent"}) does not match epochLink.prevEpochId (${link.prevEpochId})`
390
+ );
391
+ }
392
+ if (predecessor.publicKeyB64 !== link.prevPublicKeyB64) {
393
+ inconsistencies.push(
394
+ "the referenced proof's signer key does not match epochLink.prevPublicKeyB64"
395
+ );
396
+ }
397
+ if (!countersEqual(predecessor.counter, link.prevCounter)) {
398
+ inconsistencies.push(
399
+ `the referenced proof's counter (${predecessor.counter ?? "absent"}) does not match epochLink.prevCounter (${link.prevCounter})`
400
+ );
401
+ }
402
+
403
+ const predecessorValid = await isIntrinsicallyValid(predecessor);
404
+ const metadataConsistent = inconsistencies.length === 0;
405
+ const predecessorPartitioned = partitionOf.has(predecessor.proofHash);
406
+
407
+ return {
408
+ fromEpochId: link.prevEpochId,
409
+ toEpochId: link.toEpochId,
410
+ viaProofHash: viaProof.proofHash,
411
+ link,
412
+ resolution: "matched",
413
+ predecessorProofHash: predecessor.proofHash,
414
+ metadataConsistent,
415
+ ...(metadataConsistent ? {} : { inconsistencies }),
416
+ ...(predecessorPartitioned
417
+ ? { referencedProofIsTerminal: !hasInPartitionSuccessor.has(predecessor.proofHash) }
418
+ : {}),
419
+ viaProofValid,
420
+ predecessorValid,
421
+ hardEdge: metadataConsistent && viaProofValid && predecessorValid,
422
+ };
423
+ }
424
+
425
+ function countersEqual(a: string | undefined, b: string): boolean {
426
+ if (a === undefined) return false;
427
+ if (a === b) return true;
428
+ const na = parseCounter(a);
429
+ const nb = parseCounter(b);
430
+ return na !== undefined && nb !== undefined && na === nb;
431
+ }
432
+
433
+ /**
434
+ * Transitive closure over hard edges. Pairs reachable in both directions
435
+ * (only a lineage cycle produces this) are removed: contradictory
436
+ * ordering evidence is never asserted either way. The cycle itself is
437
+ * classified by the anomaly stage.
438
+ */
439
+ function deriveOrderedPairs(
440
+ edges: EpochLineageEdge[]
441
+ ): Array<{ beforeEpochId: string; afterEpochId: string }> {
442
+ const adjacency = new Map<string, Set<string>>();
443
+ const nodes = new Set<string>();
444
+ for (const edge of edges) {
445
+ if (!edge.hardEdge) continue;
446
+ addToSetMap(adjacency, edge.fromEpochId, edge.toEpochId);
447
+ nodes.add(edge.fromEpochId);
448
+ nodes.add(edge.toEpochId);
449
+ }
450
+
451
+ const pairs = new Set<string>();
452
+ for (const start of nodes) {
453
+ // Iterative DFS reachability.
454
+ const reached = new Set<string>();
455
+ const stack = [...(adjacency.get(start) ?? [])];
456
+ while (stack.length > 0) {
457
+ const node = stack.pop() as string;
458
+ if (reached.has(node)) continue;
459
+ reached.add(node);
460
+ for (const next of adjacency.get(node) ?? []) stack.push(next);
461
+ }
462
+ for (const node of reached) {
463
+ if (node !== start) pairs.add(`${start}${node}`);
464
+ }
465
+ }
466
+
467
+ const result: Array<{ beforeEpochId: string; afterEpochId: string }> = [];
468
+ for (const pair of pairs) {
469
+ const [before, after] = pair.split("") as [string, string];
470
+ if (pairs.has(`${after}${before}`)) continue; // contradictory: assert neither
471
+ result.push({ beforeEpochId: before, afterEpochId: after });
472
+ }
473
+ result.sort((a, b) =>
474
+ a.beforeEpochId !== b.beforeEpochId
475
+ ? a.beforeEpochId < b.beforeEpochId
476
+ ? -1
477
+ : 1
478
+ : a.afterEpochId < b.afterEpochId
479
+ ? -1
480
+ : a.afterEpochId > b.afterEpochId
481
+ ? 1
482
+ : 0
483
+ );
484
+ return result;
485
+ }
486
+
487
+ function addToSetMap(map: Map<string, Set<string>>, key: string, value: string): void {
488
+ const set = map.get(key);
489
+ if (set === undefined) map.set(key, new Set([value]));
490
+ else set.add(value);
491
+ }