@actuarial-ts/compliance 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bundle.ts CHANGED
@@ -26,10 +26,15 @@
26
26
  * responsibility for compliance remains with the credentialed actuary.
27
27
  */
28
28
 
29
- import { canonicalJson, fnv1a64 } from "@actuarial-ts/core";
29
+ import {
30
+ canonicalJson,
31
+ fnv1a64,
32
+ isDiagnosticPlainRecord,
33
+ } from "@actuarial-ts/core";
30
34
  import {
31
35
  INTERCHANGE_SPEC_VERSION,
32
36
  diagnosticDefinitionToDoc,
37
+ docToDiagnosticDefinition,
33
38
  type DiagnosticDefinitionDoc,
34
39
  BundleDoc,
35
40
  MethodResultDoc,
@@ -37,11 +42,22 @@ import {
37
42
  StochasticResultDoc,
38
43
  TriangleDoc,
39
44
  } from "@actuarial-ts/interchange";
40
- import type { VerifiedDiagnosticRunProvenance } from "./diagnosticRun.js";
41
- import { assertVerifiedDiagnosticRunProvenance, verifiedDefinitionForBundle } from "./diagnosticRun.js";
45
+ import type {
46
+ DiagnosticRunProvenance,
47
+ VerifiedDiagnosticRunProvenance,
48
+ } from "./diagnosticRun.js";
49
+ import {
50
+ assertVerifiedDiagnosticRunProvenance,
51
+ serializedDiagnosticRunMismatch,
52
+ verifiedDefinitionForBundle,
53
+ } from "./diagnosticRun.js";
42
54
  import { ComplianceError } from "./errors.js";
43
55
  import { COMPLIANCE_PACKAGE_VERSION } from "./version.js";
44
- export { ComplianceError, COMPLIANCE_ERROR_CODES, type ComplianceErrorCode } from "./errors.js";
56
+ export {
57
+ ComplianceError,
58
+ COMPLIANCE_ERROR_CODES,
59
+ type ComplianceErrorCode,
60
+ } from "./errors.js";
45
61
  export { COMPLIANCE_PACKAGE_VERSION } from "./version.js";
46
62
 
47
63
  export { canonicalJson, fnv1a64 };
@@ -64,8 +80,6 @@ export type WrappedBundleDoc = BundleDoc;
64
80
  * (UNSUPPORTED_VALUE moved to core's registry with canonicalJson in 0.2.0.)
65
81
  */
66
82
 
67
-
68
-
69
83
  /**
70
84
  * The interchange version the wrapped form is written under. Kept in literal
71
85
  * sync with `@actuarial-ts/interchange`'s INTERCHANGE_SPEC_VERSION (the dep
@@ -158,9 +172,13 @@ export interface WrappedBundleResult extends ReproducibilityBundle {
158
172
  * ALSO returned as a wrapped BundleDoc; the unwrapped `{ payload, hash }` is
159
173
  * byte-identical either way (the v0.1.x compat fixture pins this).
160
174
  */
161
- export function createBundle(input: CreateBundleInput & { wrap: BundleWrapInput }): WrappedBundleResult;
175
+ export function createBundle(
176
+ input: CreateBundleInput & { wrap: BundleWrapInput },
177
+ ): WrappedBundleResult;
162
178
  export function createBundle(input: CreateBundleInput): ReproducibilityBundle;
163
- export function createBundle(input: CreateBundleInput): ReproducibilityBundle | WrappedBundleResult {
179
+ export function createBundle(
180
+ input: CreateBundleInput,
181
+ ): ReproducibilityBundle | WrappedBundleResult {
164
182
  const body: Record<string, unknown> = {
165
183
  createdAt: input.createdAt,
166
184
  inputs: input.inputs,
@@ -170,8 +188,60 @@ export function createBundle(input: CreateBundleInput): ReproducibilityBundle |
170
188
  };
171
189
  if (input.seeds !== undefined) body["seeds"] = input.seeds;
172
190
  if (input.diagnosticRuns !== undefined) {
173
- for (const run of input.diagnosticRuns) assertVerifiedDiagnosticRunProvenance(run);
174
- if(input.diagnosticRuns.length>0){const expected=input.diagnosticRuns[0]!.manifest.packageVersions;for(const [runIndex,run] of input.diagnosticRuns.entries()){if(canonicalJson(run.manifest.packageVersions)!==canonicalJson(expected))throw new ComplianceError("BAD_DIAGNOSTIC_RUN","Diagnostic runs disagree on SDK package versions",`$.diagnosticRuns[${runIndex}].manifest.packageVersions`)}for(const [name,version] of Object.entries(expected))if(input.sdkVersions[name]!==version)throw new ComplianceError("BAD_DIAGNOSTIC_RUN",`Outer sdkVersions must record ${name} at ${version}`,`$.sdkVersions.${name}`);if(input.wrap!==undefined&&input.generator!==undefined&&(input.generator.name!=="@actuarial-ts/compliance"||input.generator.version!==COMPLIANCE_PACKAGE_VERSION))throw new ComplianceError("BAD_DIAGNOSTIC_RUN","Wrapped diagnostic bundles require the actual compliance generator stamp","$.generator")}
191
+ for (const run of input.diagnosticRuns)
192
+ assertVerifiedDiagnosticRunProvenance(run);
193
+ const seenBindings = new Set<string>();
194
+ for (const [runIndex, run] of input.diagnosticRuns.entries()) {
195
+ if (seenBindings.has(run.runResultFingerprint))
196
+ throw new ComplianceError(
197
+ "BAD_DIAGNOSTIC_RUN",
198
+ "Duplicate diagnostic run-result fingerprint",
199
+ `$.diagnosticRuns[${runIndex}].runResultFingerprint`,
200
+ );
201
+ seenBindings.add(run.runResultFingerprint);
202
+ }
203
+ if (input.diagnosticRuns.length > 0) {
204
+ if (!isDiagnosticPlainRecord(input.sdkVersions))
205
+ throw new ComplianceError(
206
+ "BAD_DIAGNOSTIC_RUN",
207
+ "Diagnostic bundles require a plain SDK-version map",
208
+ "$.sdkVersions",
209
+ );
210
+ const expected = input.diagnosticRuns[0]!.manifest.engine.packages;
211
+ for (const [runIndex, run] of input.diagnosticRuns.entries()) {
212
+ if (
213
+ canonicalJson(run.manifest.engine.packages) !==
214
+ canonicalJson(expected)
215
+ )
216
+ throw new ComplianceError(
217
+ "BAD_DIAGNOSTIC_RUN",
218
+ "Diagnostic runs disagree on SDK package versions",
219
+ `$.diagnosticRuns[${runIndex}].manifest.engine.packages`,
220
+ );
221
+ }
222
+ for (const [name, version] of Object.entries({
223
+ "@actuarial-ts/core": expected.core,
224
+ "@actuarial-ts/data": expected.data,
225
+ "@actuarial-ts/compliance": expected.compliance,
226
+ }))
227
+ if (input.sdkVersions[name] !== version)
228
+ throw new ComplianceError(
229
+ "BAD_DIAGNOSTIC_RUN",
230
+ `Outer sdkVersions must record ${name} at ${version}`,
231
+ `$.sdkVersions.${name}`,
232
+ );
233
+ if (
234
+ input.wrap !== undefined &&
235
+ input.generator !== undefined &&
236
+ (input.generator.name !== "@actuarial-ts/compliance" ||
237
+ input.generator.version !== COMPLIANCE_PACKAGE_VERSION)
238
+ )
239
+ throw new ComplianceError(
240
+ "BAD_DIAGNOSTIC_RUN",
241
+ "Wrapped diagnostic bundles require the actual compliance generator stamp",
242
+ "$.generator",
243
+ );
244
+ }
175
245
  body["diagnosticRuns"] = input.diagnosticRuns;
176
246
  }
177
247
  const payload = canonicalJson(body);
@@ -181,27 +251,48 @@ export function createBundle(input: CreateBundleInput): ReproducibilityBundle |
181
251
  // The inner record is carried opaquely as the `bundle` segment; the outer
182
252
  // tag is fnv1a64(canonicalJson({ bundle, interchange })) — exactly
183
253
  // interchange's semanticBodyOf for kind "bundle" (spec 3.2).
184
- const bundleSegment: Record<string, unknown> = { hash: inner.hash, payload: inner.payload };
254
+ const bundleSegment: Record<string, unknown> = {
255
+ hash: inner.hash,
256
+ payload: inner.payload,
257
+ };
185
258
  const definitions = new Map<string, DiagnosticDefinitionDoc>();
186
259
  for (const run of input.diagnosticRuns ?? []) {
187
260
  const compiled = verifiedDefinitionForBundle(run);
188
- definitions.set(run.definitionIdentities.definition, diagnosticDefinitionToDoc(compiled, {
189
- createdAt: input.createdAt,
190
- generator: { name: "@actuarial-ts/compliance", version: COMPLIANCE_PACKAGE_VERSION },
191
- }));
261
+ definitions.set(
262
+ run.definition.identities.definition,
263
+ diagnosticDefinitionToDoc(compiled, {
264
+ createdAt: input.createdAt,
265
+ generator: {
266
+ name: "@actuarial-ts/compliance",
267
+ version: COMPLIANCE_PACKAGE_VERSION,
268
+ },
269
+ }),
270
+ );
192
271
  }
193
272
  const interchange = {
194
273
  triangles: [...input.wrap.triangles],
195
274
  selections: [...input.wrap.selections],
196
275
  results: [...input.wrap.results],
197
- ...(definitions.size === 0 ? {} : { diagnosticDefinitions: [...definitions.values()].sort((a,b)=>a.diagnosticDefinition.identities.definition<b.diagnosticDefinition.identities.definition?-1:1) }),
276
+ ...(definitions.size === 0
277
+ ? {}
278
+ : {
279
+ diagnosticDefinitions: [...definitions.values()].sort((a, b) =>
280
+ a.diagnosticDefinition.identities.definition <
281
+ b.diagnosticDefinition.identities.definition
282
+ ? -1
283
+ : 1,
284
+ ),
285
+ }),
198
286
  };
199
287
  const wrapped: WrappedBundleDoc = {
200
288
  interchangeVersion: WRAPPED_BUNDLE_INTERCHANGE_VERSION,
201
289
  kind: "bundle",
202
290
  generator: input.generator
203
291
  ? { ...input.generator }
204
- : { name: "@actuarial-ts/compliance", version: COMPLIANCE_PACKAGE_VERSION },
292
+ : {
293
+ name: "@actuarial-ts/compliance",
294
+ version: COMPLIANCE_PACKAGE_VERSION,
295
+ },
205
296
  createdAt: input.createdAt,
206
297
  bundle: bundleSegment,
207
298
  interchange,
@@ -236,18 +327,29 @@ export interface VerifyBundleResult {
236
327
  }
237
328
 
238
329
  function isPlainObject(value: unknown): value is Record<string, unknown> {
239
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
330
+ if (value === null || typeof value !== "object" || Array.isArray(value))
331
+ return false;
240
332
  const proto: unknown = Object.getPrototypeOf(value);
241
333
  return proto === Object.prototype || proto === null;
242
334
  }
243
335
 
244
336
  /** First differing path between two canonicalizable structures, or null when equal. */
245
- function firstDifference(stored: unknown, rerun: unknown, path: string): string | null {
337
+ function firstDifference(
338
+ stored: unknown,
339
+ rerun: unknown,
340
+ path: string,
341
+ ): string | null {
246
342
  if (isPlainObject(stored) && isPlainObject(rerun)) {
247
- const keys = [...new Set([...Object.keys(stored), ...Object.keys(rerun)])].sort();
343
+ const keys = [
344
+ ...new Set([...Object.keys(stored), ...Object.keys(rerun)]),
345
+ ].sort();
248
346
  for (const key of keys) {
249
347
  const keyPath = `${path}.${key}`;
250
- if (!(key in stored) || !(key in rerun)) return keyPath;
348
+ if (
349
+ !Object.prototype.hasOwnProperty.call(stored, key) ||
350
+ !Object.prototype.hasOwnProperty.call(rerun, key)
351
+ )
352
+ return keyPath;
251
353
  const diff = firstDifference(stored[key], rerun[key], keyPath);
252
354
  if (diff !== null) return diff;
253
355
  }
@@ -265,7 +367,10 @@ function firstDifference(stored: unknown, rerun: unknown, path: string): string
265
367
  }
266
368
 
267
369
  /** Inner verification — the pre-wrapped behavior, unchanged byte for byte. */
268
- function verifyUnwrapped(bundle: ReproducibilityBundle, rerunResults: unknown): VerifyBundleResult {
370
+ function verifyUnwrapped(
371
+ bundle: ReproducibilityBundle,
372
+ rerunResults: unknown,
373
+ ): VerifyBundleResult {
269
374
  // The bundle's own hash first. Without this, the attestation ("these results
270
375
  // came from exactly these inputs, parameters, and SDK versions") checked the
271
376
  // results segment only — a bundle with rewritten inputs and hash "deadbeef"
@@ -281,21 +386,68 @@ function verifyUnwrapped(bundle: ReproducibilityBundle, rerunResults: unknown):
281
386
  throw new ComplianceError("BAD_BUNDLE", "bundle payload is not valid JSON");
282
387
  }
283
388
  if (!isPlainObject(stored) || !("results" in stored)) {
284
- throw new ComplianceError("BAD_BUNDLE", 'bundle payload has no "results" segment');
389
+ throw new ComplianceError(
390
+ "BAD_BUNDLE",
391
+ 'bundle payload has no "results" segment',
392
+ );
393
+ }
394
+ if (Object.prototype.hasOwnProperty.call(stored, "diagnosticRuns")) {
395
+ if (!Array.isArray(stored["diagnosticRuns"]))
396
+ throw new ComplianceError(
397
+ "BAD_BUNDLE",
398
+ 'bundle payload "diagnosticRuns" must be an array',
399
+ );
400
+ const seen = new Set<string>();
401
+ for (const [index, run] of stored["diagnosticRuns"].entries()) {
402
+ const path = `$.diagnosticRuns[${index}]`;
403
+ const mismatch = serializedDiagnosticRunMismatch(run, path);
404
+ if (mismatch !== null)
405
+ return { reproduced: false, mismatchPath: mismatch };
406
+ const fingerprint = (run as Record<string, unknown>)[
407
+ "runResultFingerprint"
408
+ ] as string;
409
+ if (seen.has(fingerprint))
410
+ return {
411
+ reproduced: false,
412
+ mismatchPath: `${path}.runResultFingerprint`,
413
+ };
414
+ seen.add(fingerprint);
415
+ const packages = (run as DiagnosticRunProvenance).manifest.engine
416
+ .packages;
417
+ for (const [name, shortName] of [
418
+ ["@actuarial-ts/core", "core"],
419
+ ["@actuarial-ts/data", "data"],
420
+ ["@actuarial-ts/compliance", "compliance"],
421
+ ] as const) {
422
+ if (
423
+ !isPlainObject(stored["sdkVersions"]) ||
424
+ stored["sdkVersions"][name] !== packages?.[shortName]
425
+ )
426
+ return { reproduced: false, mismatchPath: `$.sdkVersions.${name}` };
427
+ }
428
+ }
285
429
  }
286
430
  const storedResults = stored["results"];
287
431
  const rerunCanonical = canonicalJson(rerunResults);
288
432
  const storedCanonical = canonicalJson(storedResults);
289
433
  if (rerunCanonical === storedCanonical) return { reproduced: true };
290
- return { reproduced: false, mismatchPath: firstDifference(storedResults, rerunResults, "$") ?? "$" };
434
+ return {
435
+ reproduced: false,
436
+ mismatchPath: firstDifference(storedResults, rerunResults, "$") ?? "$",
437
+ };
291
438
  }
292
439
 
293
- function isWrappedBundleDoc(value: ReproducibilityBundle | WrappedBundleDoc): value is WrappedBundleDoc {
440
+ function isWrappedBundleDoc(
441
+ value: ReproducibilityBundle | WrappedBundleDoc,
442
+ ): value is WrappedBundleDoc {
294
443
  return (value as { kind?: unknown }).kind === "bundle";
295
444
  }
296
445
 
297
446
  /** Wrapped mode: the outer tag (spec 3.2) AND the inner bundle exactly as today. */
298
- function verifyWrapped(doc: WrappedBundleDoc, rerunResults: unknown): VerifyBundleResult {
447
+ function verifyWrapped(
448
+ doc: WrappedBundleDoc,
449
+ rerunResults: unknown,
450
+ ): VerifyBundleResult {
299
451
  const innerRaw: unknown = doc.bundle;
300
452
  if (
301
453
  !isPlainObject(innerRaw) ||
@@ -308,18 +460,104 @@ function verifyWrapped(doc: WrappedBundleDoc, rerunResults: unknown): VerifyBund
308
460
  );
309
461
  }
310
462
  if (doc.interchange === undefined || doc.interchange === null) {
311
- throw new ComplianceError("BAD_BUNDLE", 'wrapped bundle is missing its "interchange" mirror');
463
+ throw new ComplianceError(
464
+ "BAD_BUNDLE",
465
+ 'wrapped bundle is missing its "interchange" mirror',
466
+ );
312
467
  }
313
468
  // OUTER tag over the two-field semantic body { bundle, interchange } —
314
469
  // exactly interchange's semanticBodyOf for kind "bundle" (spec 3.2).
315
- const expected = fnv1a64(canonicalJson({ bundle: doc.bundle, interchange: doc.interchange }));
470
+ const expected = fnv1a64(
471
+ canonicalJson({ bundle: doc.bundle, interchange: doc.interchange }),
472
+ );
316
473
  const actual = typeof doc.integrity === "string" ? doc.integrity : null;
317
- const outerIntegrity: OuterIntegrityCheck = { ok: expected === actual, expected, actual };
474
+ const outerIntegrity: OuterIntegrityCheck = {
475
+ ok: expected === actual,
476
+ expected,
477
+ actual,
478
+ };
318
479
  if (!outerIntegrity.ok) {
319
480
  return { reproduced: false, mismatchPath: "$.integrity", outerIntegrity };
320
481
  }
321
- const inner: ReproducibilityBundle = { payload: innerRaw["payload"], hash: innerRaw["hash"] };
322
- return { ...verifyUnwrapped(inner, rerunResults), outerIntegrity };
482
+ const inner: ReproducibilityBundle = {
483
+ payload: innerRaw["payload"],
484
+ hash: innerRaw["hash"],
485
+ };
486
+ const innerResult = verifyUnwrapped(inner, rerunResults);
487
+ if (!innerResult.reproduced) return { ...innerResult, outerIntegrity };
488
+ const body = JSON.parse(inner.payload) as Record<string, unknown>;
489
+ const runs = Array.isArray(body["diagnosticRuns"])
490
+ ? body["diagnosticRuns"]
491
+ : [];
492
+ const docs = (doc.interchange as { diagnosticDefinitions?: unknown })
493
+ .diagnosticDefinitions;
494
+ if (runs.length > 0) {
495
+ const packages = (runs[0] as DiagnosticRunProvenance).manifest.engine
496
+ .packages;
497
+ if (doc.generator?.name !== "@actuarial-ts/compliance")
498
+ return {
499
+ reproduced: false,
500
+ mismatchPath: "$.generator.name",
501
+ outerIntegrity,
502
+ };
503
+ if (doc.generator.version !== packages.compliance)
504
+ return {
505
+ reproduced: false,
506
+ mismatchPath: "$.generator.version",
507
+ outerIntegrity,
508
+ };
509
+ if (!Array.isArray(docs))
510
+ return {
511
+ reproduced: false,
512
+ mismatchPath: "$.interchange.diagnosticDefinitions",
513
+ outerIntegrity,
514
+ };
515
+ const expected = new Set(
516
+ runs.map(
517
+ (run) =>
518
+ (run as DiagnosticRunProvenance).definition.identities.definition,
519
+ ),
520
+ );
521
+ const actual = new Set<string>();
522
+ for (const [index, raw] of docs.entries()) {
523
+ try {
524
+ const document = raw as DiagnosticDefinitionDoc;
525
+ const integrity =
526
+ docToDiagnosticDefinition(document).definition.definitionIntegrity;
527
+ if (actual.has(integrity))
528
+ return {
529
+ reproduced: false,
530
+ mismatchPath: `$.interchange.diagnosticDefinitions[${index}]`,
531
+ outerIntegrity,
532
+ };
533
+ if (
534
+ document.generator.name !== doc.generator.name ||
535
+ document.generator.version !== doc.generator.version
536
+ )
537
+ return {
538
+ reproduced: false,
539
+ mismatchPath: `$.interchange.diagnosticDefinitions[${index}].generator`,
540
+ outerIntegrity,
541
+ };
542
+ actual.add(integrity);
543
+ } catch {
544
+ return {
545
+ reproduced: false,
546
+ mismatchPath: `$.interchange.diagnosticDefinitions[${index}]`,
547
+ outerIntegrity,
548
+ };
549
+ }
550
+ }
551
+ if (
552
+ canonicalJson([...actual].sort()) !== canonicalJson([...expected].sort())
553
+ )
554
+ return {
555
+ reproduced: false,
556
+ mismatchPath: "$.interchange.diagnosticDefinitions",
557
+ outerIntegrity,
558
+ };
559
+ }
560
+ return { ...innerResult, outerIntegrity };
323
561
  }
324
562
 
325
563
  /**
@@ -344,5 +582,7 @@ export function verifyBundle(
344
582
  bundle: ReproducibilityBundle | WrappedBundleDoc,
345
583
  rerunResults: unknown,
346
584
  ): VerifyBundleResult {
347
- return isWrappedBundleDoc(bundle) ? verifyWrapped(bundle, rerunResults) : verifyUnwrapped(bundle, rerunResults);
585
+ return isWrappedBundleDoc(bundle)
586
+ ? verifyWrapped(bundle, rerunResults)
587
+ : verifyUnwrapped(bundle, rerunResults);
348
588
  }