@mikeargento/bitgraph 1.1.0 → 1.2.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 (195) hide show
  1. package/README.md +49 -40
  2. package/dist/__tests__/audit-anchors.test.d.ts +2 -0
  3. package/dist/__tests__/audit-anchors.test.d.ts.map +1 -0
  4. package/dist/__tests__/audit-anchors.test.js +152 -0
  5. package/dist/__tests__/audit-anchors.test.js.map +1 -0
  6. package/dist/__tests__/audit-anomalies.test.d.ts +2 -0
  7. package/dist/__tests__/audit-anomalies.test.d.ts.map +1 -0
  8. package/dist/__tests__/audit-anomalies.test.js +663 -0
  9. package/dist/__tests__/audit-anomalies.test.js.map +1 -0
  10. package/dist/__tests__/audit-attestation.test.d.ts +2 -0
  11. package/dist/__tests__/audit-attestation.test.d.ts.map +1 -0
  12. package/dist/__tests__/audit-attestation.test.js +403 -0
  13. package/dist/__tests__/audit-attestation.test.js.map +1 -0
  14. package/dist/__tests__/audit-bench-smoke.test.d.ts +2 -0
  15. package/dist/__tests__/audit-bench-smoke.test.d.ts.map +1 -0
  16. package/dist/__tests__/audit-bench-smoke.test.js +87 -0
  17. package/dist/__tests__/audit-bench-smoke.test.js.map +1 -0
  18. package/dist/__tests__/audit-boundary.test.d.ts +20 -0
  19. package/dist/__tests__/audit-boundary.test.d.ts.map +1 -0
  20. package/dist/__tests__/audit-boundary.test.js +84 -0
  21. package/dist/__tests__/audit-boundary.test.js.map +1 -0
  22. package/dist/__tests__/audit-cli.test.d.ts +2 -0
  23. package/dist/__tests__/audit-cli.test.d.ts.map +1 -0
  24. package/dist/__tests__/audit-cli.test.js +239 -0
  25. package/dist/__tests__/audit-cli.test.js.map +1 -0
  26. package/dist/__tests__/audit-dependencies.test.d.ts +2 -0
  27. package/dist/__tests__/audit-dependencies.test.d.ts.map +1 -0
  28. package/dist/__tests__/audit-dependencies.test.js +116 -0
  29. package/dist/__tests__/audit-dependencies.test.js.map +1 -0
  30. package/dist/__tests__/audit-fixtures.d.ts +265 -0
  31. package/dist/__tests__/audit-fixtures.d.ts.map +1 -0
  32. package/dist/__tests__/audit-fixtures.js +511 -0
  33. package/dist/__tests__/audit-fixtures.js.map +1 -0
  34. package/dist/__tests__/audit-ingest.test.d.ts +2 -0
  35. package/dist/__tests__/audit-ingest.test.d.ts.map +1 -0
  36. package/dist/__tests__/audit-ingest.test.js +473 -0
  37. package/dist/__tests__/audit-ingest.test.js.map +1 -0
  38. package/dist/__tests__/audit-memory-ingest.test.d.ts +12 -0
  39. package/dist/__tests__/audit-memory-ingest.test.d.ts.map +1 -0
  40. package/dist/__tests__/audit-memory-ingest.test.js +120 -0
  41. package/dist/__tests__/audit-memory-ingest.test.js.map +1 -0
  42. package/dist/__tests__/audit-property.test.d.ts +2 -0
  43. package/dist/__tests__/audit-property.test.d.ts.map +1 -0
  44. package/dist/__tests__/audit-property.test.js +771 -0
  45. package/dist/__tests__/audit-property.test.js.map +1 -0
  46. package/dist/__tests__/audit-real-fixtures.test.d.ts +2 -0
  47. package/dist/__tests__/audit-real-fixtures.test.d.ts.map +1 -0
  48. package/dist/__tests__/audit-real-fixtures.test.js +132 -0
  49. package/dist/__tests__/audit-real-fixtures.test.js.map +1 -0
  50. package/dist/__tests__/audit-real-live.test.d.ts +2 -0
  51. package/dist/__tests__/audit-real-live.test.d.ts.map +1 -0
  52. package/dist/__tests__/audit-real-live.test.js +107 -0
  53. package/dist/__tests__/audit-real-live.test.js.map +1 -0
  54. package/dist/__tests__/audit-reconstruct.test.d.ts +2 -0
  55. package/dist/__tests__/audit-reconstruct.test.d.ts.map +1 -0
  56. package/dist/__tests__/audit-reconstruct.test.js +287 -0
  57. package/dist/__tests__/audit-reconstruct.test.js.map +1 -0
  58. package/dist/__tests__/audit-report.test.d.ts +2 -0
  59. package/dist/__tests__/audit-report.test.d.ts.map +1 -0
  60. package/dist/__tests__/audit-report.test.js +186 -0
  61. package/dist/__tests__/audit-report.test.js.map +1 -0
  62. package/dist/__tests__/audit-roundtrip.test.d.ts +2 -0
  63. package/dist/__tests__/audit-roundtrip.test.d.ts.map +1 -0
  64. package/dist/__tests__/audit-roundtrip.test.js +368 -0
  65. package/dist/__tests__/audit-roundtrip.test.js.map +1 -0
  66. package/dist/__tests__/audit-temporal.test.d.ts +2 -0
  67. package/dist/__tests__/audit-temporal.test.d.ts.map +1 -0
  68. package/dist/__tests__/audit-temporal.test.js +364 -0
  69. package/dist/__tests__/audit-temporal.test.js.map +1 -0
  70. package/dist/__tests__/audit-tiers.test.d.ts +2 -0
  71. package/dist/__tests__/audit-tiers.test.d.ts.map +1 -0
  72. package/dist/__tests__/audit-tiers.test.js +282 -0
  73. package/dist/__tests__/audit-tiers.test.js.map +1 -0
  74. package/dist/__tests__/audit-witness-real.test.d.ts +19 -0
  75. package/dist/__tests__/audit-witness-real.test.d.ts.map +1 -0
  76. package/dist/__tests__/audit-witness-real.test.js +82 -0
  77. package/dist/__tests__/audit-witness-real.test.js.map +1 -0
  78. package/dist/__tests__/audit-witness.test.d.ts +2 -0
  79. package/dist/__tests__/audit-witness.test.d.ts.map +1 -0
  80. package/dist/__tests__/audit-witness.test.js +308 -0
  81. package/dist/__tests__/audit-witness.test.js.map +1 -0
  82. package/dist/__tests__/chain-hash-anchor.test.d.ts +22 -0
  83. package/dist/__tests__/chain-hash-anchor.test.d.ts.map +1 -0
  84. package/dist/__tests__/chain-hash-anchor.test.js +55 -0
  85. package/dist/__tests__/chain-hash-anchor.test.js.map +1 -0
  86. package/dist/__tests__/declared-proof-fixture.d.ts +66 -0
  87. package/dist/__tests__/declared-proof-fixture.d.ts.map +1 -0
  88. package/dist/__tests__/declared-proof-fixture.js +66 -0
  89. package/dist/__tests__/declared-proof-fixture.js.map +1 -0
  90. package/dist/__tests__/fuse-roundtrip.test.d.ts +2 -0
  91. package/dist/__tests__/fuse-roundtrip.test.d.ts.map +1 -0
  92. package/dist/__tests__/fuse-roundtrip.test.js +172 -0
  93. package/dist/__tests__/fuse-roundtrip.test.js.map +1 -0
  94. package/dist/__tests__/fuse-sdk.test.d.ts +2 -0
  95. package/dist/__tests__/fuse-sdk.test.d.ts.map +1 -0
  96. package/dist/__tests__/fuse-sdk.test.js +117 -0
  97. package/dist/__tests__/fuse-sdk.test.js.map +1 -0
  98. package/dist/__tests__/fuse-vectors.test.d.ts +2 -0
  99. package/dist/__tests__/fuse-vectors.test.d.ts.map +1 -0
  100. package/dist/__tests__/fuse-vectors.test.js +175 -0
  101. package/dist/__tests__/fuse-vectors.test.js.map +1 -0
  102. package/dist/__tests__/fuse-verify.test.d.ts +2 -0
  103. package/dist/__tests__/fuse-verify.test.d.ts.map +1 -0
  104. package/dist/__tests__/fuse-verify.test.js +250 -0
  105. package/dist/__tests__/fuse-verify.test.js.map +1 -0
  106. package/dist/__tests__/mock-proof-fixture.d.ts +15 -0
  107. package/dist/__tests__/mock-proof-fixture.d.ts.map +1 -0
  108. package/dist/__tests__/mock-proof-fixture.js +34 -0
  109. package/dist/__tests__/mock-proof-fixture.js.map +1 -0
  110. package/dist/__tests__/proof-hash-regression.test.js +4 -43
  111. package/dist/__tests__/proof-hash-regression.test.js.map +1 -1
  112. package/dist/__tests__/proof-hash.test.js +3 -21
  113. package/dist/__tests__/proof-hash.test.js.map +1 -1
  114. package/dist/__tests__/proof-integrity.test.d.ts +2 -0
  115. package/dist/__tests__/proof-integrity.test.d.ts.map +1 -0
  116. package/dist/__tests__/proof-integrity.test.js +426 -0
  117. package/dist/__tests__/proof-integrity.test.js.map +1 -0
  118. package/dist/__tests__/realistic-proof-fixture.d.ts +56 -0
  119. package/dist/__tests__/realistic-proof-fixture.d.ts.map +1 -0
  120. package/dist/__tests__/realistic-proof-fixture.js +57 -0
  121. package/dist/__tests__/realistic-proof-fixture.js.map +1 -0
  122. package/dist/__tests__/verifier.test.js +74 -0
  123. package/dist/__tests__/verifier.test.js.map +1 -1
  124. package/dist/fuse-cli.d.ts +3 -0
  125. package/dist/fuse-cli.d.ts.map +1 -0
  126. package/dist/fuse-cli.js +210 -0
  127. package/dist/fuse-cli.js.map +1 -0
  128. package/dist/fuse.d.ts +75 -0
  129. package/dist/fuse.d.ts.map +1 -0
  130. package/dist/fuse.js +273 -0
  131. package/dist/fuse.js.map +1 -0
  132. package/dist/index.d.ts +2 -0
  133. package/dist/index.d.ts.map +1 -1
  134. package/dist/index.js +4 -0
  135. package/dist/index.js.map +1 -1
  136. package/package.json +19 -9
  137. package/src/__tests__/audit-anchors.test.ts +188 -0
  138. package/src/__tests__/audit-anomalies.test.ts +815 -0
  139. package/src/__tests__/audit-attestation.test.ts +529 -0
  140. package/src/__tests__/audit-bench-smoke.test.ts +108 -0
  141. package/src/__tests__/audit-boundary.test.ts +96 -0
  142. package/src/__tests__/audit-cli.test.ts +288 -0
  143. package/src/__tests__/audit-dependencies.test.ts +141 -0
  144. package/src/__tests__/audit-fixtures.ts +736 -0
  145. package/src/__tests__/audit-ingest.test.ts +575 -0
  146. package/src/__tests__/audit-memory-ingest.test.ts +140 -0
  147. package/src/__tests__/audit-property.test.ts +892 -0
  148. package/src/__tests__/audit-real-fixtures.test.ts +153 -0
  149. package/src/__tests__/audit-real-live.test.ts +128 -0
  150. package/src/__tests__/audit-reconstruct.test.ts +353 -0
  151. package/src/__tests__/audit-report.test.ts +222 -0
  152. package/src/__tests__/audit-roundtrip.test.ts +464 -0
  153. package/src/__tests__/audit-temporal.test.ts +446 -0
  154. package/src/__tests__/audit-tiers.test.ts +343 -0
  155. package/src/__tests__/audit-witness-real.test.ts +92 -0
  156. package/src/__tests__/audit-witness.test.ts +345 -0
  157. package/src/__tests__/chain-hash-anchor.test.ts +67 -0
  158. package/src/__tests__/declared-proof-fixture.ts +65 -0
  159. package/src/__tests__/fuse-roundtrip.test.ts +174 -0
  160. package/src/__tests__/fuse-sdk.test.ts +142 -0
  161. package/src/__tests__/fuse-vectors.test.ts +218 -0
  162. package/src/__tests__/fuse-verify.test.ts +278 -0
  163. package/src/__tests__/mock-proof-fixture.ts +36 -0
  164. package/src/__tests__/proof-hash-regression.test.ts +4 -44
  165. package/src/__tests__/proof-hash.test.ts +3 -22
  166. package/src/__tests__/proof-integrity.test.ts +541 -0
  167. package/src/__tests__/real-fixtures/anchor-after-112762.json +47 -0
  168. package/src/__tests__/real-fixtures/anchor-with-ethereum-112758.json +47 -0
  169. package/src/__tests__/real-fixtures/live-proof-0.json +42 -0
  170. package/src/__tests__/real-fixtures/live-proof-1.json +43 -0
  171. package/src/__tests__/real-fixtures/live-proof-2.json +43 -0
  172. package/src/__tests__/real-fixtures/successor-112760.json +38 -0
  173. package/src/__tests__/real-fixtures/witness-25525146.json +6 -0
  174. package/src/__tests__/real-fixtures/witness-25525147.json +6 -0
  175. package/src/__tests__/realistic-proof-fixture.ts +57 -0
  176. package/src/__tests__/verifier.test.ts +83 -0
  177. package/src/fuse-cli.ts +187 -0
  178. package/src/fuse.ts +356 -0
  179. package/src/index.ts +6 -0
  180. package/dist/canonical.d.ts +0 -54
  181. package/dist/canonical.d.ts.map +0 -1
  182. package/dist/canonical.js +0 -119
  183. package/dist/canonical.js.map +0 -1
  184. package/dist/proof-hash.d.ts +0 -9
  185. package/dist/proof-hash.d.ts.map +0 -1
  186. package/dist/proof-hash.js +0 -75
  187. package/dist/proof-hash.js.map +0 -1
  188. package/dist/types.d.ts +0 -705
  189. package/dist/types.d.ts.map +0 -1
  190. package/dist/types.js +0 -3
  191. package/dist/types.js.map +0 -1
  192. package/dist/verifier.d.ts +0 -27
  193. package/dist/verifier.d.ts.map +0 -1
  194. package/dist/verifier.js +0 -844
  195. package/dist/verifier.js.map +0 -1
@@ -0,0 +1,892 @@
1
+ // Copyright (c) Mike Argento. All rights reserved. See LICENSE.
2
+
3
+ /**
4
+ * Property-based topology tests for the audit pipeline (Phase 4e).
5
+ *
6
+ * A seeded deterministic PRNG (mulberry32, fixed literal seeds below, no
7
+ * Date.now, no unseeded Math.random) drives a synthetic topology generator
8
+ * that produces protocol-correct chains through the REAL signing path:
9
+ * real Ed25519 signatures over the canonical SignedBody, real
10
+ * computeProofHash links, and the G2 two-position slot/commit counter
11
+ * pattern (slot 1/commit 2, slot 3/commit 4, ...). A mutation engine then
12
+ * injects each anomaly class deliberately, each class in its own isolated
13
+ * partition (fresh key, fresh epochId) so expected codes compose by
14
+ * union.
15
+ *
16
+ * Assertions:
17
+ * - each class in isolation produces exactly its stable codes plus the
18
+ * class-specific structure (components, divergences, lineage edges);
19
+ * - the property loop composes a random subset of classes per seed and
20
+ * asserts EXACTLY the injected anomaly codes appear: no missing
21
+ * detections, no spurious extras (control chains contribute zero);
22
+ * - determinism: regenerating the same seed into a fresh directory
23
+ * yields a deep-equal JSON report modulo runMetadata.
24
+ *
25
+ * No verifier semantics are bypassed anywhere: every proof that must be
26
+ * valid is signed for real, and deliberately absent proofs are simply not
27
+ * written to the bundle.
28
+ */
29
+
30
+ import { describe, it, after } from "node:test";
31
+ import * as assert from "node:assert/strict";
32
+ import { rm } from "node:fs/promises";
33
+ import { sha256 } from "@noble/hashes/sha256";
34
+ import { computeProofHash } from "@mikeargento/bitgraph-verify";
35
+ import type { BitGraphProof } from "@mikeargento/bitgraph-verify";
36
+ import { buildJsonReport, runAudit } from "@mikeargento/bitgraph-audit";
37
+ import type { AuditResult, ReportPartition } from "@mikeargento/bitgraph-audit";
38
+ import {
39
+ b64,
40
+ makeCounterChain,
41
+ makeEpochLinkProof,
42
+ makeKey,
43
+ makeTempDir,
44
+ proofJson,
45
+ signBody,
46
+ utf8,
47
+ writeBundleDir,
48
+ healthyPairs,
49
+ type CounterChainLink,
50
+ type RandomSource,
51
+ } from "./audit-fixtures.js";
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Seeded deterministic PRNG (mulberry32). Fixed seeds only; runs are
55
+ // reproducible byte for byte.
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function mulberry32(seed: number): () => number {
59
+ let a = seed >>> 0;
60
+ return () => {
61
+ a = (a + 0x6d2b79f5) >>> 0;
62
+ let t = a;
63
+ t = Math.imul(t ^ (t >>> 15), t | 1);
64
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
65
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
66
+ };
67
+ }
68
+
69
+ function randomSource(rng: () => number): RandomSource {
70
+ return (byteLength: number) => {
71
+ const out = new Uint8Array(byteLength);
72
+ for (let i = 0; i < byteLength; i++) out[i] = Math.floor(rng() * 256);
73
+ return out;
74
+ };
75
+ }
76
+
77
+ function randInt(rng: () => number, min: number, max: number): number {
78
+ return min + Math.floor(rng() * (max - min + 1));
79
+ }
80
+
81
+ /** Fixed seeds for the property loop. Chosen once; never derived from the clock. */
82
+ const PROPERTY_SEEDS = [
83
+ 0x00000001, 0x1bad5eed, 0x2c0ffee5, 0x3d0d0bee, 0x4e1e5701, 0x5a5a5a5a,
84
+ 0x6b17c0de, 0x7f00ba75, 0x8badf00d, 0x9e3779b9, 0xa11ce001, 0xb0bca742,
85
+ ] as const;
86
+
87
+ /** Seed used by the isolation tests and the determinism test. */
88
+ const ISOLATION_SEED = 0xf1c5e0e5;
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Topology builder context
92
+ // ---------------------------------------------------------------------------
93
+
94
+ interface TopologyContext {
95
+ rng: () => number;
96
+ random: RandomSource;
97
+ files: Record<string, string | Uint8Array>;
98
+ /** Expected stable anomaly codes, as a multiset. */
99
+ expectedCodes: string[];
100
+ /** Expected unique observed proofs. */
101
+ expectedObserved: number;
102
+ /** Expected unsupported-version rejects. */
103
+ expectedUnsupported: number;
104
+ /** Expected chainless proofs. */
105
+ expectedChainless: number;
106
+ /** Unique path prefix per scenario instance. */
107
+ nextPrefix: (className: string) => string;
108
+ }
109
+
110
+ function makeContext(seed: number): TopologyContext {
111
+ const rng = mulberry32(seed);
112
+ let scenarioIndex = 0;
113
+ return {
114
+ rng,
115
+ random: randomSource(rng),
116
+ files: {},
117
+ expectedCodes: [],
118
+ expectedObserved: 0,
119
+ expectedUnsupported: 0,
120
+ expectedChainless: 0,
121
+ nextPrefix: (className: string) => `s${String(scenarioIndex++).padStart(2, "0")}-${className}`,
122
+ };
123
+ }
124
+
125
+ function addChainFiles(ctx: TopologyContext, prefix: string, proofs: CounterChainLink[]): void {
126
+ for (let i = 0; i < proofs.length; i++) {
127
+ ctx.files[`${prefix}/proof-${i}.json`] = proofJson(proofs[i]!.proof);
128
+ }
129
+ ctx.expectedObserved += proofs.length;
130
+ }
131
+
132
+ /** Random chainId variant: the named production chain or the enclave default. */
133
+ function pickChainId(ctx: TopologyContext): string | undefined {
134
+ return ctx.rng() < 0.5 ? "bitgraph:main" : undefined;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Mutation engine: one builder per anomaly class. Every builder works in
139
+ // its own partition (fresh PRNG key, unique epochId), so expected codes
140
+ // compose by union across an arbitrary subset of classes.
141
+ // ---------------------------------------------------------------------------
142
+
143
+ type ScenarioBuilder = (ctx: TopologyContext) => Promise<void>;
144
+
145
+ /** Continuous healthy chain: the control. Contributes zero codes. */
146
+ const buildControl: ScenarioBuilder = async (ctx) => {
147
+ const prefix = ctx.nextPrefix("control");
148
+ const length = randInt(ctx.rng, 3, 6);
149
+ const chainId = pickChainId(ctx);
150
+ const chain = await makeCounterChain({
151
+ epochId: `${prefix}-epoch`,
152
+ pairs: healthyPairs(length),
153
+ ...(chainId !== undefined ? { chainId } : {}),
154
+ payloadPrefix: prefix,
155
+ random: ctx.random,
156
+ });
157
+ addChainFiles(ctx, prefix, chain.proofs);
158
+ };
159
+
160
+ /** Removed proof: its slot+commit positions unexplained plus a chain break. */
161
+ const buildRemovedProof: ScenarioBuilder = async (ctx) => {
162
+ const prefix = ctx.nextPrefix("removed");
163
+ const length = randInt(ctx.rng, 3, 6);
164
+ const dropIndex = randInt(ctx.rng, 1, length - 2); // never genesis, never tail
165
+ const chain = await makeCounterChain({
166
+ epochId: `${prefix}-epoch`,
167
+ pairs: healthyPairs(length),
168
+ payloadPrefix: prefix,
169
+ random: ctx.random,
170
+ });
171
+ for (let i = 0; i < chain.proofs.length; i++) {
172
+ if (i === dropIndex) continue; // deliberately absent from the bundle
173
+ ctx.files[`${prefix}/proof-${i}.json`] = proofJson(chain.proofs[i]!.proof);
174
+ }
175
+ ctx.expectedObserved += length - 1;
176
+ ctx.expectedCodes.push("unexplained-counter-positions", "chain-break-missing");
177
+ };
178
+
179
+ /** Two proofs signed for the same commit counter (distinct slots, no shared prev). */
180
+ const buildCounterCollision: ScenarioBuilder = async (ctx) => {
181
+ const prefix = ctx.nextPrefix("counter-collision");
182
+ const length = randInt(ctx.rng, 3, 5);
183
+ const chain = await makeCounterChain({
184
+ epochId: `${prefix}-epoch`,
185
+ pairs: healthyPairs(length),
186
+ payloadPrefix: prefix,
187
+ random: ctx.random,
188
+ });
189
+ addChainFiles(ctx, prefix, chain.proofs);
190
+ const collideWith = chain.proofs[1]!.proof.commit.counter as string; // "4"
191
+ const tail = chain.proofs[length - 1]!;
192
+ // The collider reuses only the commit counter and carries no slotCounter
193
+ // of its own, so this scenario stays a pure counter collision: it never
194
+ // reuses another proof's slot (slot-collision) and never reuses a commit
195
+ // position as its own slot (cross-kind-position-reuse), both of which are
196
+ // exercised by their own dedicated scenarios.
197
+ const extra = await makeCounterChain({
198
+ epochId: `${prefix}-epoch`,
199
+ key: chain.key,
200
+ pairs: [{ commit: collideWith }],
201
+ payloadPrefix: `${prefix}-collider`,
202
+ prevB64OfFirst: tail.chainHash,
203
+ random: ctx.random,
204
+ });
205
+ ctx.files[`${prefix}/collider.json`] = proofJson(extra.proofs[0]!.proof);
206
+ ctx.expectedObserved += 1;
207
+ ctx.expectedCodes.push("counter-collision");
208
+ };
209
+
210
+ /** Two proofs referencing the same slot counter. */
211
+ const buildSlotCollision: ScenarioBuilder = async (ctx) => {
212
+ const prefix = ctx.nextPrefix("slot-collision");
213
+ const length = randInt(ctx.rng, 3, 5);
214
+ const chain = await makeCounterChain({
215
+ epochId: `${prefix}-epoch`,
216
+ pairs: healthyPairs(length),
217
+ payloadPrefix: prefix,
218
+ random: ctx.random,
219
+ });
220
+ addChainFiles(ctx, prefix, chain.proofs);
221
+ const collideWith = chain.proofs[1]!.proof.commit.slotCounter as string;
222
+ const tail = chain.proofs[length - 1]!;
223
+ const extra = await makeCounterChain({
224
+ epochId: `${prefix}-epoch`,
225
+ key: chain.key,
226
+ pairs: [{ slot: collideWith, commit: String(2 * length + 1) }],
227
+ payloadPrefix: `${prefix}-collider`,
228
+ prevB64OfFirst: tail.chainHash,
229
+ random: ctx.random,
230
+ });
231
+ ctx.files[`${prefix}/collider.json`] = proofJson(extra.proofs[0]!.proof);
232
+ ctx.expectedObserved += 1;
233
+ ctx.expectedCodes.push("slot-collision");
234
+ };
235
+
236
+ /** Predecessor-reuse fork: two valid successors consume the same tail. */
237
+ const buildPredecessorReuse: ScenarioBuilder = async (ctx) => {
238
+ const prefix = ctx.nextPrefix("fork");
239
+ const length = randInt(ctx.rng, 2, 4);
240
+ const chain = await makeCounterChain({
241
+ epochId: `${prefix}-epoch`,
242
+ pairs: healthyPairs(length),
243
+ payloadPrefix: prefix,
244
+ random: ctx.random,
245
+ });
246
+ addChainFiles(ctx, prefix, chain.proofs);
247
+ const tail = chain.proofs[length - 1]!;
248
+ for (const [branch, offset] of [
249
+ ["a", 0],
250
+ ["b", 2],
251
+ ] as const) {
252
+ const child = await makeCounterChain({
253
+ epochId: `${prefix}-epoch`,
254
+ key: chain.key,
255
+ pairs: [{ slot: String(2 * length + 1 + offset), commit: String(2 * length + 2 + offset) }],
256
+ payloadPrefix: `${prefix}-branch-${branch}`,
257
+ prevB64OfFirst: tail.chainHash,
258
+ random: ctx.random,
259
+ });
260
+ ctx.files[`${prefix}/branch-${branch}.json`] = proofJson(child.proofs[0]!.proof);
261
+ ctx.expectedObserved += 1;
262
+ }
263
+ ctx.expectedCodes.push("predecessor-reuse");
264
+ };
265
+
266
+ /** Dangling prevB64: well-formed 32-byte reference into unobserved history. */
267
+ const buildMissingPredecessor: ScenarioBuilder = async (ctx) => {
268
+ const prefix = ctx.nextPrefix("dangling-prev");
269
+ const length = randInt(ctx.rng, 2, 4);
270
+ const chain = await makeCounterChain({
271
+ epochId: `${prefix}-epoch`,
272
+ pairs: healthyPairs(length),
273
+ payloadPrefix: prefix,
274
+ random: ctx.random,
275
+ });
276
+ addChainFiles(ctx, prefix, chain.proofs);
277
+ const stray = await makeCounterChain({
278
+ epochId: `${prefix}-epoch`,
279
+ key: chain.key,
280
+ pairs: [{ slot: String(2 * length + 1), commit: String(2 * length + 2) }],
281
+ payloadPrefix: `${prefix}-stray`,
282
+ prevB64OfFirst: b64(ctx.random(32)), // no observed proof has this hash
283
+ random: ctx.random,
284
+ });
285
+ ctx.files[`${prefix}/stray.json`] = proofJson(stray.proofs[0]!.proof);
286
+ ctx.expectedObserved += 1;
287
+ ctx.expectedCodes.push("chain-break-missing");
288
+ };
289
+
290
+ /** Malformed prevB64: not base64 of 32 bytes. The proof itself stays verifier-valid. */
291
+ const buildMalformedPrev: ScenarioBuilder = async (ctx) => {
292
+ const prefix = ctx.nextPrefix("malformed-prev");
293
+ const length = randInt(ctx.rng, 2, 4);
294
+ const chain = await makeCounterChain({
295
+ epochId: `${prefix}-epoch`,
296
+ pairs: healthyPairs(length),
297
+ payloadPrefix: prefix,
298
+ random: ctx.random,
299
+ });
300
+ addChainFiles(ctx, prefix, chain.proofs);
301
+ const malformed = await makeCounterChain({
302
+ epochId: `${prefix}-epoch`,
303
+ key: chain.key,
304
+ pairs: [{ slot: String(2 * length + 1), commit: String(2 * length + 2) }],
305
+ payloadPrefix: `${prefix}-malformed`,
306
+ prevB64OfFirst: "this-is-not-base64-of-32-bytes!",
307
+ random: ctx.random,
308
+ });
309
+ ctx.files[`${prefix}/malformed.json`] = proofJson(malformed.proofs[0]!.proof);
310
+ ctx.expectedObserved += 1;
311
+ ctx.expectedCodes.push("chain-break-malformed");
312
+ };
313
+
314
+ /** Byte-identical duplicate proof file. Benign; still a stable code. */
315
+ const buildExactDuplicate: ScenarioBuilder = async (ctx) => {
316
+ const prefix = ctx.nextPrefix("exact-dup");
317
+ const chain = await makeCounterChain({
318
+ epochId: `${prefix}-epoch`,
319
+ pairs: healthyPairs(2),
320
+ payloadPrefix: prefix,
321
+ random: ctx.random,
322
+ });
323
+ addChainFiles(ctx, prefix, chain.proofs);
324
+ ctx.files[`${prefix}/copy-of-0.json`] = proofJson(chain.proofs[0]!.proof);
325
+ ctx.expectedCodes.push("exact-duplicate");
326
+ };
327
+
328
+ /**
329
+ * Semantic duplicate: the same proof re-serialized with a different key
330
+ * order and an extra UNSIGNED metadata field, so the bytes differ but the
331
+ * canonical identity matches. The builder verifies the computeProofHash
332
+ * equality before relying on it (metadata sits outside the signed body).
333
+ */
334
+ const buildSemanticDuplicate: ScenarioBuilder = async (ctx) => {
335
+ const prefix = ctx.nextPrefix("semantic-dup");
336
+ const chain = await makeCounterChain({
337
+ epochId: `${prefix}-epoch`,
338
+ pairs: healthyPairs(2),
339
+ payloadPrefix: prefix,
340
+ random: ctx.random,
341
+ });
342
+ addChainFiles(ctx, prefix, chain.proofs);
343
+ const original = chain.proofs[0]!.proof;
344
+ const reserialized = {
345
+ environment: original.environment,
346
+ signer: original.signer,
347
+ commit: original.commit,
348
+ artifact: original.artifact,
349
+ version: original.version,
350
+ metadata: { note: "unsigned metadata added by a relay", relay: prefix },
351
+ };
352
+ const reserializedJson = JSON.stringify(reserialized);
353
+ assert.notEqual(reserializedJson, proofJson(original), "bytes must differ");
354
+ assert.equal(
355
+ computeProofHash(JSON.parse(reserializedJson) as Record<string, unknown>),
356
+ chain.proofs[0]!.proofHash,
357
+ "re-serialization with reordered keys and unsigned metadata must keep the canonical identity"
358
+ );
359
+ ctx.files[`${prefix}/reserialized-0.json`] = reserializedJson;
360
+ ctx.expectedCodes.push("semantic-duplicate");
361
+ };
362
+
363
+ /** Two disconnected components in one partition: a second no-prevB64 genesis. */
364
+ const buildDisconnectedChains: ScenarioBuilder = async (ctx) => {
365
+ const prefix = ctx.nextPrefix("disconnected");
366
+ const first = await makeCounterChain({
367
+ epochId: `${prefix}-epoch`,
368
+ pairs: healthyPairs(2),
369
+ payloadPrefix: `${prefix}-one`,
370
+ random: ctx.random,
371
+ });
372
+ addChainFiles(ctx, `${prefix}/one`, first.proofs);
373
+ // Same key and epoch, later counters, no link back: a second genesis.
374
+ const second = await makeCounterChain({
375
+ epochId: `${prefix}-epoch`,
376
+ key: first.key,
377
+ pairs: [
378
+ { slot: "5", commit: "6" },
379
+ { slot: "7", commit: "8" },
380
+ ],
381
+ payloadPrefix: `${prefix}-two`,
382
+ random: ctx.random,
383
+ });
384
+ addChainFiles(ctx, `${prefix}/two`, second.proofs);
385
+ ctx.expectedCodes.push("multiple-genesis");
386
+ };
387
+
388
+ /** Valid epochLink transition: prior epoch terminal consumed by a new epoch. Zero codes. */
389
+ const buildValidEpochLink: ScenarioBuilder = async (ctx) => {
390
+ const prefix = ctx.nextPrefix("epochlink-valid");
391
+ const length = randInt(ctx.rng, 2, 3);
392
+ // One measurement per epoch: an epochId is boot-scoped, so the genesis
393
+ // and its continuation declare the same measurement (a mix would
394
+ // honestly flag mid-epoch-measurement-change).
395
+ const measurementB = `${prefix}-measurement-b`;
396
+ const prior = await makeCounterChain({
397
+ epochId: `${prefix}-epoch-a`,
398
+ pairs: healthyPairs(length),
399
+ measurement: `${prefix}-measurement-a`,
400
+ payloadPrefix: `${prefix}-a`,
401
+ random: ctx.random,
402
+ });
403
+ addChainFiles(ctx, `${prefix}/a`, prior.proofs);
404
+ const terminal = prior.proofs[length - 1]!;
405
+ const genesis = await makeEpochLinkProof({
406
+ prevEpochId: `${prefix}-epoch-a`,
407
+ prevCounter: terminal.proof.commit.counter as string,
408
+ prevProofHashB64: terminal.chainHash,
409
+ toEpochId: `${prefix}-epoch-b`,
410
+ prevPublicKeyB64: prior.key.publicKeyB64,
411
+ counter: "2",
412
+ slotCounter: "1",
413
+ measurement: measurementB,
414
+ payload: `${prefix}-b-genesis`,
415
+ random: ctx.random,
416
+ });
417
+ ctx.files[`${prefix}/b-genesis.json`] = proofJson(genesis.proof);
418
+ const continuation = await makeCounterChain({
419
+ epochId: `${prefix}-epoch-b`,
420
+ key: genesis.key,
421
+ pairs: [{ slot: "3", commit: "4" }],
422
+ measurement: measurementB,
423
+ payloadPrefix: `${prefix}-b`,
424
+ prevB64OfFirst: genesis.chainHash,
425
+ random: ctx.random,
426
+ });
427
+ ctx.files[`${prefix}/b-1.json`] = proofJson(continuation.proofs[0]!.proof);
428
+ ctx.expectedObserved += 2;
429
+ };
430
+
431
+ /** Broken epochLink: dangling reference to an unobserved epoch and terminal. */
432
+ const buildDanglingEpochLink: ScenarioBuilder = async (ctx) => {
433
+ const prefix = ctx.nextPrefix("epochlink-dangling");
434
+ const genesis = await makeEpochLinkProof({
435
+ prevEpochId: `${prefix}-unobserved-epoch`,
436
+ prevCounter: "88",
437
+ prevProofHashB64: b64(ctx.random(32)),
438
+ toEpochId: `${prefix}-epoch`,
439
+ counter: "2",
440
+ slotCounter: "1",
441
+ payload: `${prefix}-genesis`,
442
+ random: ctx.random,
443
+ });
444
+ ctx.files[`${prefix}/genesis.json`] = proofJson(genesis.proof);
445
+ ctx.expectedObserved += 1;
446
+ ctx.expectedCodes.push("epochlink-dangling");
447
+ };
448
+
449
+ /** Multiple independent lineages: two signers, two epochs. Zero codes, never merged. */
450
+ const buildMultipleLineages: ScenarioBuilder = async (ctx) => {
451
+ const prefix = ctx.nextPrefix("lineages");
452
+ for (const which of ["x", "y"] as const) {
453
+ const chain = await makeCounterChain({
454
+ epochId: `${prefix}-epoch-${which}`,
455
+ pairs: healthyPairs(randInt(ctx.rng, 2, 3)),
456
+ payloadPrefix: `${prefix}-${which}`,
457
+ random: ctx.random,
458
+ });
459
+ addChainFiles(ctx, `${prefix}/${which}`, chain.proofs);
460
+ }
461
+ };
462
+
463
+ /** Mid-epoch measurement transition: one signer, one epoch, linked chain, two measurements. */
464
+ const buildMeasurementChange: ScenarioBuilder = async (ctx) => {
465
+ const prefix = ctx.nextPrefix("measurement-change");
466
+ const first = await makeCounterChain({
467
+ epochId: `${prefix}-epoch`,
468
+ pairs: healthyPairs(2),
469
+ measurement: `${prefix}-measurement-one`,
470
+ payloadPrefix: `${prefix}-one`,
471
+ random: ctx.random,
472
+ });
473
+ addChainFiles(ctx, `${prefix}/one`, first.proofs);
474
+ const second = await makeCounterChain({
475
+ epochId: `${prefix}-epoch`,
476
+ key: first.key,
477
+ pairs: [{ slot: "5", commit: "6" }],
478
+ measurement: `${prefix}-measurement-two`,
479
+ payloadPrefix: `${prefix}-two`,
480
+ prevB64OfFirst: first.proofs[1]!.chainHash,
481
+ random: ctx.random,
482
+ });
483
+ ctx.files[`${prefix}/two.json`] = proofJson(second.proofs[0]!.proof);
484
+ ctx.expectedObserved += 1;
485
+ ctx.expectedCodes.push("mid-epoch-measurement-change");
486
+ };
487
+
488
+ /** Illegal mid-epoch signer change: two distinct keys under one epochId. */
489
+ const buildSignerChange: ScenarioBuilder = async (ctx) => {
490
+ const prefix = ctx.nextPrefix("signer-change");
491
+ const epochId = `${prefix}-epoch`;
492
+ const first = await makeCounterChain({
493
+ epochId,
494
+ pairs: healthyPairs(2),
495
+ payloadPrefix: `${prefix}-one`,
496
+ random: ctx.random,
497
+ });
498
+ addChainFiles(ctx, `${prefix}/one`, first.proofs);
499
+ const secondKey = await makeKey(ctx.random);
500
+ const second = await makeCounterChain({
501
+ epochId,
502
+ key: secondKey,
503
+ pairs: [
504
+ { slot: "5", commit: "6" },
505
+ { slot: "7", commit: "8" },
506
+ ],
507
+ payloadPrefix: `${prefix}-two`,
508
+ random: ctx.random,
509
+ });
510
+ addChainFiles(ctx, `${prefix}/two`, second.proofs);
511
+ ctx.expectedCodes.push("mid-epoch-signer-change");
512
+ };
513
+
514
+ /** Chainless proofs: no counter, no epochId. Observed-but-unchained, never an anomaly. */
515
+ const buildChainless: ScenarioBuilder = async (ctx) => {
516
+ const prefix = ctx.nextPrefix("chainless");
517
+ const count = randInt(ctx.rng, 1, 2);
518
+ const key = await makeKey(ctx.random);
519
+ for (let i = 0; i < count; i++) {
520
+ const bytes = utf8(`${prefix}-payload-${i}`);
521
+ const proof = await signBody(
522
+ key,
523
+ { hashAlg: "sha256", digestB64: b64(sha256(bytes)) },
524
+ { nonceB64: b64(ctx.random(16)) },
525
+ `${prefix}-measurement`
526
+ );
527
+ ctx.files[`${prefix}/proof-${i}.json`] = proofJson(proof);
528
+ }
529
+ ctx.expectedObserved += count;
530
+ ctx.expectedChainless += count;
531
+ };
532
+
533
+ /** occ/1 reject: pre-release beta data, rejected at ingest per the version policy. */
534
+ const buildOccReject: ScenarioBuilder = async (ctx) => {
535
+ const prefix = ctx.nextPrefix("occ1");
536
+ ctx.files[`${prefix}/legacy.json`] = JSON.stringify({
537
+ version: "occ/1",
538
+ artifact: { hashAlg: "sha256", digestB64: b64(ctx.random(32)) },
539
+ commit: { nonceB64: b64(ctx.random(16)), counter: "7" },
540
+ signer: { publicKeyB64: b64(ctx.random(32)), signatureB64: b64(ctx.random(64)) },
541
+ });
542
+ ctx.expectedUnsupported += 1;
543
+ ctx.expectedCodes.push("unsupported-version");
544
+ };
545
+
546
+ const MUTATIONS: ReadonlyArray<[name: string, builder: ScenarioBuilder]> = [
547
+ ["removed-proof", buildRemovedProof],
548
+ ["counter-collision", buildCounterCollision],
549
+ ["slot-collision", buildSlotCollision],
550
+ ["predecessor-reuse", buildPredecessorReuse],
551
+ ["missing-predecessor", buildMissingPredecessor],
552
+ ["malformed-prev", buildMalformedPrev],
553
+ ["exact-duplicate", buildExactDuplicate],
554
+ ["semantic-duplicate", buildSemanticDuplicate],
555
+ ["disconnected-chains", buildDisconnectedChains],
556
+ ["epochlink-valid", buildValidEpochLink],
557
+ ["epochlink-dangling", buildDanglingEpochLink],
558
+ ["multiple-lineages", buildMultipleLineages],
559
+ ["measurement-change", buildMeasurementChange],
560
+ ["signer-change", buildSignerChange],
561
+ ["chainless", buildChainless],
562
+ ["occ1-reject", buildOccReject],
563
+ ];
564
+
565
+ // ---------------------------------------------------------------------------
566
+ // Shared runner
567
+ // ---------------------------------------------------------------------------
568
+
569
+ const tempDirs: string[] = [];
570
+
571
+ after(async () => {
572
+ await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })));
573
+ });
574
+
575
+ async function buildAndAudit(
576
+ ctx: TopologyContext
577
+ ): Promise<{ dir: string; result: AuditResult }> {
578
+ const dir = await makeTempDir("bitgraph-audit-property-");
579
+ tempDirs.push(dir);
580
+ await writeBundleDir(dir, ctx.files);
581
+ const result = await runAudit(dir);
582
+ return { dir, result };
583
+ }
584
+
585
+ function assertExactCodes(result: AuditResult, ctx: TopologyContext, label: string): void {
586
+ const report = buildJsonReport(result);
587
+ const actual = report.anomalies.map((a) => a.code).sort();
588
+ const expected = [...ctx.expectedCodes].sort();
589
+ assert.deepEqual(actual, expected, `${label}: exactly the injected anomaly codes appear`);
590
+ assert.equal(result.ingest.counts.observed, ctx.expectedObserved, `${label}: observed count`);
591
+ assert.equal(
592
+ result.ingest.counts.unsupportedVersion,
593
+ ctx.expectedUnsupported,
594
+ `${label}: unsupported-version count`
595
+ );
596
+ assert.equal(result.verification.chainless, ctx.expectedChainless, `${label}: chainless count`);
597
+ // Every synthetic proof is signed through the real path with no artifact
598
+ // bytes bundled: nothing may fail verification, nothing may be "verified".
599
+ assert.equal(result.verification.failed, 0, `${label}: no verification failures`);
600
+ assert.equal(result.verification.verified, 0, `${label}: no artifact bytes, no full tier`);
601
+ assert.equal(
602
+ result.verification.artifactUnavailable,
603
+ ctx.expectedObserved,
604
+ `${label}: all proofs at the integrity tier`
605
+ );
606
+ }
607
+
608
+ function findPartition(result: AuditResult, epochId: string): ReportPartition | undefined {
609
+ const report = buildJsonReport(result);
610
+ return report.partitions.find((p) => p.key.epochId === epochId);
611
+ }
612
+
613
+ // ---------------------------------------------------------------------------
614
+ // Isolation: each anomaly class alone produces exactly its codes plus the
615
+ // class-specific structure.
616
+ // ---------------------------------------------------------------------------
617
+
618
+ describe("audit property tests: anomaly classes in isolation", () => {
619
+ it("control: a continuous healthy chain produces zero anomaly codes", async () => {
620
+ const ctx = makeContext(ISOLATION_SEED);
621
+ await buildControl(ctx);
622
+ const { result } = await buildAndAudit(ctx);
623
+ assertExactCodes(result, ctx, "control");
624
+ assert.equal(result.reconstruction.partitions.length, 1);
625
+ assert.equal(result.reconstruction.partitions[0]?.components.length, 1);
626
+ assert.equal(buildJsonReport(result).summary.chainIntact, true);
627
+ });
628
+
629
+ it("removed proof: gap covers exactly the dropped slot and commit positions, plus a chain break", async () => {
630
+ const ctx = makeContext(ISOLATION_SEED + 1);
631
+ // Deterministic variant: length 4, drop index 1 (slot 3 / commit 4).
632
+ const prefix = ctx.nextPrefix("removed-fixed");
633
+ const chain = await makeCounterChain({
634
+ epochId: `${prefix}-epoch`,
635
+ pairs: healthyPairs(4),
636
+ payloadPrefix: prefix,
637
+ random: ctx.random,
638
+ });
639
+ for (let i = 0; i < 4; i++) {
640
+ if (i === 1) continue;
641
+ ctx.files[`${prefix}/proof-${i}.json`] = proofJson(chain.proofs[i]!.proof);
642
+ }
643
+ ctx.expectedObserved += 3;
644
+ ctx.expectedCodes.push("unexplained-counter-positions", "chain-break-missing");
645
+ const { result } = await buildAndAudit(ctx);
646
+ assertExactCodes(result, ctx, "removed");
647
+
648
+ const gap = result.anomalies.anomalies.find((a) => a.code === "unexplained-counter-positions");
649
+ assert.deepEqual((gap?.details as { positions: string[] }).positions, ["3", "4"]);
650
+ const brk = result.anomalies.anomalies.find((a) => a.code === "chain-break-missing");
651
+ assert.deepEqual(brk?.proofHashes, [chain.proofs[2]!.proofHash]);
652
+ assert.equal((brk?.details as { prevB64: string }).prevB64, chain.proofs[1]!.chainHash);
653
+ // The removal splits the partition into two observed components.
654
+ assert.equal(findPartition(result, `${prefix}-epoch`)?.components.length, 2);
655
+ });
656
+
657
+ it("counter collision: two valid proofs on one commit counter, divergence preserved, no winner", async () => {
658
+ const ctx = makeContext(ISOLATION_SEED + 2);
659
+ await buildCounterCollision(ctx);
660
+ const { result } = await buildAndAudit(ctx);
661
+ assertExactCodes(result, ctx, "counter-collision");
662
+ assert.equal(result.anomalies.divergences.length, 1);
663
+ const divergence = result.anomalies.divergences[0]!;
664
+ assert.equal(divergence.kind, "counter-collision");
665
+ assert.equal(divergence.parties.length, 2);
666
+ assert.equal(divergence.invalidContext.length, 0);
667
+ assert.ok(divergence.explanation.includes("does not choose"));
668
+ });
669
+
670
+ it("slot collision: two valid proofs referencing one slot counter", async () => {
671
+ const ctx = makeContext(ISOLATION_SEED + 3);
672
+ await buildSlotCollision(ctx);
673
+ const { result } = await buildAndAudit(ctx);
674
+ assertExactCodes(result, ctx, "slot-collision");
675
+ assert.equal(result.anomalies.divergences.length, 1);
676
+ assert.equal(result.anomalies.divergences[0]?.kind, "slot-collision");
677
+ });
678
+
679
+ it("predecessor reuse: a fork with both branches preserved", async () => {
680
+ const ctx = makeContext(ISOLATION_SEED + 4);
681
+ await buildPredecessorReuse(ctx);
682
+ const { result } = await buildAndAudit(ctx);
683
+ assertExactCodes(result, ctx, "predecessor-reuse");
684
+ assert.equal(result.anomalies.divergences.length, 1);
685
+ const divergence = result.anomalies.divergences[0]!;
686
+ assert.equal(divergence.kind, "predecessor-reuse");
687
+ assert.equal(divergence.parties.length, 2);
688
+ // Both branches verifier-valid at the integrity tier.
689
+ for (const party of divergence.parties) {
690
+ assert.equal(party.verificationStatus, "artifact-unavailable");
691
+ }
692
+ });
693
+
694
+ it("missing predecessor: dangling prevB64 is a chain break into unobserved history", async () => {
695
+ const ctx = makeContext(ISOLATION_SEED + 5);
696
+ await buildMissingPredecessor(ctx);
697
+ const { result } = await buildAndAudit(ctx);
698
+ assertExactCodes(result, ctx, "missing-predecessor");
699
+ const brk = result.anomalies.anomalies.find((a) => a.code === "chain-break-missing");
700
+ assert.ok(brk?.message.includes("does not, by itself, establish"));
701
+ });
702
+
703
+ it("malformed prevB64: unusable link flagged; the proof itself stays verifier-valid", async () => {
704
+ const ctx = makeContext(ISOLATION_SEED + 6);
705
+ await buildMalformedPrev(ctx);
706
+ const { result } = await buildAndAudit(ctx);
707
+ assertExactCodes(result, ctx, "malformed-prev");
708
+ const anomaly = result.anomalies.anomalies.find((a) => a.code === "chain-break-malformed");
709
+ const flagged = result.ingest.proofs.find(
710
+ (p) => p.proofHash === anomaly?.proofHashes[0]
711
+ );
712
+ // prevB64 is inside the signed body but the canonical verifier does not
713
+ // require it to be base64; topology and verification stay separate.
714
+ assert.equal(flagged?.verification?.status, "artifact-unavailable");
715
+ });
716
+
717
+ it("exact duplicate: byte-identical copy collapses to one observed proof", async () => {
718
+ const ctx = makeContext(ISOLATION_SEED + 7);
719
+ await buildExactDuplicate(ctx);
720
+ const { result } = await buildAndAudit(ctx);
721
+ assertExactCodes(result, ctx, "exact-duplicate");
722
+ assert.equal(result.ingest.counts.exactDuplicates, 1);
723
+ assert.equal(result.ingest.counts.semanticDuplicates, 0);
724
+ });
725
+
726
+ it("semantic duplicate: reordered keys plus unsigned metadata keep the canonical identity", async () => {
727
+ const ctx = makeContext(ISOLATION_SEED + 8);
728
+ await buildSemanticDuplicate(ctx);
729
+ const { result } = await buildAndAudit(ctx);
730
+ assertExactCodes(result, ctx, "semantic-duplicate");
731
+ assert.equal(result.ingest.counts.exactDuplicates, 0);
732
+ assert.equal(result.ingest.counts.semanticDuplicates, 1);
733
+ // The two byte encodings collapsed into one observed proof with two sources.
734
+ const withTwoSources = result.ingest.proofs.filter((p) => p.sources.length === 2);
735
+ assert.equal(withTwoSources.length, 1);
736
+ });
737
+
738
+ it("disconnected chains: two components in one partition via a second genesis", async () => {
739
+ const ctx = makeContext(ISOLATION_SEED + 9);
740
+ await buildDisconnectedChains(ctx);
741
+ const { result } = await buildAndAudit(ctx);
742
+ assertExactCodes(result, ctx, "disconnected");
743
+ assert.equal(result.reconstruction.partitions.length, 1);
744
+ assert.equal(result.reconstruction.partitions[0]?.components.length, 2);
745
+ // Both genesis proofs are valid: a multiple-genesis divergence.
746
+ assert.equal(result.anomalies.divergences.length, 1);
747
+ assert.equal(result.anomalies.divergences[0]?.kind, "multiple-genesis");
748
+ });
749
+
750
+ it("valid epochLink transition: zero codes, hard lineage edge, ordered epochs", async () => {
751
+ const ctx = makeContext(ISOLATION_SEED + 10);
752
+ await buildValidEpochLink(ctx);
753
+ const { result } = await buildAndAudit(ctx);
754
+ assertExactCodes(result, ctx, "epochlink-valid");
755
+ const edges = result.reconstruction.epochRelationships.edges;
756
+ assert.equal(edges.length, 1);
757
+ assert.equal(edges[0]?.resolution, "matched");
758
+ assert.equal(edges[0]?.hardEdge, true);
759
+ assert.equal(edges[0]?.metadataConsistent, true);
760
+ const pairs = result.reconstruction.epochRelationships.orderedPairs;
761
+ assert.equal(pairs.length, 1);
762
+ assert.ok(pairs[0]?.beforeEpochId.endsWith("epoch-a"));
763
+ assert.ok(pairs[0]?.afterEpochId.endsWith("epoch-b"));
764
+ for (const epoch of result.reconstruction.epochRelationships.epochs) {
765
+ assert.equal(epoch.ordering, "linked");
766
+ }
767
+ });
768
+
769
+ it("broken epochLink: dangling lineage claim flagged, no ordering asserted", async () => {
770
+ const ctx = makeContext(ISOLATION_SEED + 11);
771
+ await buildDanglingEpochLink(ctx);
772
+ const { result } = await buildAndAudit(ctx);
773
+ assertExactCodes(result, ctx, "epochlink-dangling");
774
+ const edges = result.reconstruction.epochRelationships.edges;
775
+ assert.equal(edges.length, 1);
776
+ assert.equal(edges[0]?.resolution, "dangling");
777
+ assert.equal(edges[0]?.hardEdge, false);
778
+ assert.equal(result.reconstruction.epochRelationships.orderedPairs.length, 0);
779
+ });
780
+
781
+ it("multiple lineages: two signers never merge and never anomalize", async () => {
782
+ const ctx = makeContext(ISOLATION_SEED + 12);
783
+ await buildMultipleLineages(ctx);
784
+ const { result } = await buildAndAudit(ctx);
785
+ assertExactCodes(result, ctx, "lineages");
786
+ assert.equal(result.reconstruction.partitions.length, 2);
787
+ const keys = new Set(result.reconstruction.partitions.map((p) => p.key.publicKeyB64));
788
+ assert.equal(keys.size, 2);
789
+ // No ordering evidence between the epochs: concurrent-or-unordered.
790
+ assert.equal(buildJsonReport(result).epochRelationships.unorderedPairs.length, 1);
791
+ });
792
+
793
+ it("mid-epoch measurement transition: flagged with both measurements listed", async () => {
794
+ const ctx = makeContext(ISOLATION_SEED + 13);
795
+ await buildMeasurementChange(ctx);
796
+ const { result } = await buildAndAudit(ctx);
797
+ assertExactCodes(result, ctx, "measurement-change");
798
+ const anomaly = result.authorities.anomalies.find(
799
+ (a) => a.code === "mid-epoch-measurement-change"
800
+ );
801
+ assert.ok(anomaly !== undefined);
802
+ const details = anomaly.details as { measurements: string[] };
803
+ assert.equal(details.measurements.length, 2);
804
+ // One signer, one linked chain: still a single component.
805
+ assert.equal(result.reconstruction.partitions.length, 1);
806
+ assert.equal(result.reconstruction.partitions[0]?.components.length, 1);
807
+ // Two authority groups (measurement is a grouping facet).
808
+ assert.equal(result.authorities.groups.length, 2);
809
+ });
810
+
811
+ it("illegal mid-epoch signer change: flagged with both keys listed", async () => {
812
+ const ctx = makeContext(ISOLATION_SEED + 14);
813
+ await buildSignerChange(ctx);
814
+ const { result } = await buildAndAudit(ctx);
815
+ assertExactCodes(result, ctx, "signer-change");
816
+ const anomaly = result.authorities.anomalies.find((a) => a.code === "mid-epoch-signer-change");
817
+ assert.ok(anomaly !== undefined);
818
+ const details = anomaly.details as { publicKeysB64: string[] };
819
+ assert.equal(details.publicKeysB64.length, 2);
820
+ // Two partitions: signer lineages are never merged.
821
+ assert.equal(result.reconstruction.partitions.length, 2);
822
+ });
823
+
824
+ it("chainless proofs: observed-but-unchained, reported separately, never an anomaly", async () => {
825
+ const ctx = makeContext(ISOLATION_SEED + 15);
826
+ await buildChainless(ctx);
827
+ const { result } = await buildAndAudit(ctx);
828
+ assertExactCodes(result, ctx, "chainless");
829
+ assert.equal(result.reconstruction.partitions.length, 0);
830
+ assert.equal(
831
+ result.reconstruction.unchainedProofHashes.length,
832
+ ctx.expectedChainless
833
+ );
834
+ });
835
+
836
+ it("occ/1 reject: unsupported-version at ingest, excluded from observation and analysis", async () => {
837
+ const ctx = makeContext(ISOLATION_SEED + 16);
838
+ await buildOccReject(ctx);
839
+ await buildControl(ctx);
840
+ const { result } = await buildAndAudit(ctx);
841
+ assertExactCodes(result, ctx, "occ1");
842
+ assert.equal(result.ingest.unsupportedVersions.length, 1);
843
+ assert.equal(result.ingest.unsupportedVersions[0]?.version, "occ/1");
844
+ assert.ok(result.ingest.unsupportedVersions[0]?.path.includes("legacy.json"));
845
+ // Rejected inputs are not observed objects and join no partition.
846
+ assert.equal(result.ingest.counts.observed, ctx.expectedObserved);
847
+ });
848
+ });
849
+
850
+ // ---------------------------------------------------------------------------
851
+ // Property loop: random topologies across fixed seeds.
852
+ // ---------------------------------------------------------------------------
853
+
854
+ describe("audit property tests: random topologies across fixed seeds", () => {
855
+ for (const seed of PROPERTY_SEEDS) {
856
+ it(`seed 0x${seed.toString(16)}: exactly the injected anomaly codes appear`, async () => {
857
+ const ctx = makeContext(seed);
858
+ // Always a control chain; each mutation class joins with p = 0.5.
859
+ // Selection draws happen BEFORE any builder runs so the include/skip
860
+ // pattern is a pure function of the seed.
861
+ const included = MUTATIONS.map(() => ctx.rng() < 0.5);
862
+ await buildControl(ctx);
863
+ for (let i = 0; i < MUTATIONS.length; i++) {
864
+ if (included[i] === true) await MUTATIONS[i]![1](ctx);
865
+ }
866
+ const { result } = await buildAndAudit(ctx);
867
+ const label = `seed 0x${seed.toString(16)} [${MUTATIONS.filter((_, i) => included[i]).map(([n]) => n).join(", ")}]`;
868
+ assertExactCodes(result, ctx, label);
869
+ });
870
+ }
871
+
872
+ it("determinism: the same seed regenerated twice yields identical reports modulo runMetadata", async () => {
873
+ const build = async (): Promise<{ report: ReturnType<typeof buildJsonReport> }> => {
874
+ const ctx = makeContext(ISOLATION_SEED + 100);
875
+ const included = MUTATIONS.map(() => ctx.rng() < 0.5);
876
+ await buildControl(ctx);
877
+ for (let i = 0; i < MUTATIONS.length; i++) {
878
+ if (included[i] === true) await MUTATIONS[i]![1](ctx);
879
+ }
880
+ const { result } = await buildAndAudit(ctx);
881
+ return { report: buildJsonReport(result) };
882
+ };
883
+
884
+ const first = await build();
885
+ const second = await build();
886
+ const { runMetadata: metaA, ...restA } = first.report;
887
+ const { runMetadata: metaB, ...restB } = second.report;
888
+ assert.notEqual(metaA.bundlePath, metaB.bundlePath, "distinct temp dirs");
889
+ assert.deepEqual(restA, restB, "reports deep-equal modulo runMetadata");
890
+ assert.equal(JSON.stringify(restA), JSON.stringify(restB), "byte-equal serialization");
891
+ });
892
+ });