@actuarial-ts/compliance 0.5.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,14 +26,39 @@
26
26
  * responsibility for compliance remains with the credentialed actuary.
27
27
  */
28
28
 
29
- import { canonicalJson, fnv1a64 } from "@actuarial-ts/core";
30
- import type {
29
+ import {
30
+ canonicalJson,
31
+ fnv1a64,
32
+ isDiagnosticPlainRecord,
33
+ } from "@actuarial-ts/core";
34
+ import {
35
+ INTERCHANGE_SPEC_VERSION,
36
+ diagnosticDefinitionToDoc,
37
+ docToDiagnosticDefinition,
38
+ type DiagnosticDefinitionDoc,
31
39
  BundleDoc,
32
40
  MethodResultDoc,
33
41
  SelectionDoc,
34
42
  StochasticResultDoc,
35
43
  TriangleDoc,
36
44
  } from "@actuarial-ts/interchange";
45
+ import type {
46
+ DiagnosticRunProvenance,
47
+ VerifiedDiagnosticRunProvenance,
48
+ } from "./diagnosticRun.js";
49
+ import {
50
+ assertVerifiedDiagnosticRunProvenance,
51
+ serializedDiagnosticRunMismatch,
52
+ verifiedDefinitionForBundle,
53
+ } from "./diagnosticRun.js";
54
+ import { ComplianceError } from "./errors.js";
55
+ import { COMPLIANCE_PACKAGE_VERSION } from "./version.js";
56
+ export {
57
+ ComplianceError,
58
+ COMPLIANCE_ERROR_CODES,
59
+ type ComplianceErrorCode,
60
+ } from "./errors.js";
61
+ export { COMPLIANCE_PACKAGE_VERSION } from "./version.js";
37
62
 
38
63
  export { canonicalJson, fnv1a64 };
39
64
 
@@ -54,25 +79,6 @@ export type WrappedBundleDoc = BundleDoc;
54
79
  * of this package. Add the code here when introducing a new throw.
55
80
  * (UNSUPPORTED_VALUE moved to core's registry with canonicalJson in 0.2.0.)
56
81
  */
57
- export const COMPLIANCE_ERROR_CODES = [
58
- "BAD_BUNDLE",
59
- "BAD_CDF",
60
- "MISSING_RATIONALE",
61
- ] as const;
62
-
63
- export type ComplianceErrorCode = (typeof COMPLIANCE_ERROR_CODES)[number];
64
-
65
- /** Thrown for invalid compliance input (bad bundles, judgment without rationale, non-positive CDFs). */
66
- export class ComplianceError extends Error {
67
- readonly code: ComplianceErrorCode;
68
- constructor(code: ComplianceErrorCode, message: string) {
69
- super(message);
70
- this.name = "ComplianceError";
71
- this.code = code;
72
- }
73
- }
74
-
75
-
76
82
 
77
83
  /**
78
84
  * The interchange version the wrapped form is written under. Kept in literal
@@ -81,7 +87,7 @@ export class ComplianceError extends Error {
81
87
  * test parses the emitted doc with the real interchange parser, which
82
88
  * refuses a wrong-major version — drift fails loudly.
83
89
  */
84
- export const WRAPPED_BUNDLE_INTERCHANGE_VERSION = "1.0.0";
90
+ export const WRAPPED_BUNDLE_INTERCHANGE_VERSION = INTERCHANGE_SPEC_VERSION;
85
91
 
86
92
  /**
87
93
  * This package's version, stamped into a wrapped bundle's `generator`
@@ -89,7 +95,6 @@ export const WRAPPED_BUNDLE_INTERCHANGE_VERSION = "1.0.0";
89
95
  * silently drift (mirroring interchange's INTERCHANGE_PACKAGE_VERSION
90
96
  * discipline).
91
97
  */
92
- export const COMPLIANCE_PACKAGE_VERSION = "0.5.0";
93
98
 
94
99
  /**
95
100
  * The interchange mirror for a wrapped bundle (spec 3.2): the triangles the
@@ -136,6 +141,8 @@ export interface CreateBundleInput {
136
141
  * Wrapped-mode only; the inner payload never carries it.
137
142
  */
138
143
  generator?: { name: string; version: string };
144
+ /** Authenticated diagnostic runs included in the canonical body and, when wrapped, as typed definition documents. */
145
+ diagnosticRuns?: readonly VerifiedDiagnosticRunProvenance[];
139
146
  }
140
147
 
141
148
  export interface ReproducibilityBundle {
@@ -165,9 +172,13 @@ export interface WrappedBundleResult extends ReproducibilityBundle {
165
172
  * ALSO returned as a wrapped BundleDoc; the unwrapped `{ payload, hash }` is
166
173
  * byte-identical either way (the v0.1.x compat fixture pins this).
167
174
  */
168
- export function createBundle(input: CreateBundleInput & { wrap: BundleWrapInput }): WrappedBundleResult;
175
+ export function createBundle(
176
+ input: CreateBundleInput & { wrap: BundleWrapInput },
177
+ ): WrappedBundleResult;
169
178
  export function createBundle(input: CreateBundleInput): ReproducibilityBundle;
170
- export function createBundle(input: CreateBundleInput): ReproducibilityBundle | WrappedBundleResult {
179
+ export function createBundle(
180
+ input: CreateBundleInput,
181
+ ): ReproducibilityBundle | WrappedBundleResult {
171
182
  const body: Record<string, unknown> = {
172
183
  createdAt: input.createdAt,
173
184
  inputs: input.inputs,
@@ -176,6 +187,63 @@ export function createBundle(input: CreateBundleInput): ReproducibilityBundle |
176
187
  sdkVersions: input.sdkVersions,
177
188
  };
178
189
  if (input.seeds !== undefined) body["seeds"] = input.seeds;
190
+ if (input.diagnosticRuns !== undefined) {
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
+ }
245
+ body["diagnosticRuns"] = input.diagnosticRuns;
246
+ }
179
247
  const payload = canonicalJson(body);
180
248
  const inner: ReproducibilityBundle = { payload, hash: fnv1a64(payload) };
181
249
  if (input.wrap === undefined) return inner;
@@ -183,18 +251,48 @@ export function createBundle(input: CreateBundleInput): ReproducibilityBundle |
183
251
  // The inner record is carried opaquely as the `bundle` segment; the outer
184
252
  // tag is fnv1a64(canonicalJson({ bundle, interchange })) — exactly
185
253
  // interchange's semanticBodyOf for kind "bundle" (spec 3.2).
186
- 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
+ };
258
+ const definitions = new Map<string, DiagnosticDefinitionDoc>();
259
+ for (const run of input.diagnosticRuns ?? []) {
260
+ const compiled = verifiedDefinitionForBundle(run);
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
+ );
271
+ }
187
272
  const interchange = {
188
273
  triangles: [...input.wrap.triangles],
189
274
  selections: [...input.wrap.selections],
190
275
  results: [...input.wrap.results],
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
+ }),
191
286
  };
192
287
  const wrapped: WrappedBundleDoc = {
193
288
  interchangeVersion: WRAPPED_BUNDLE_INTERCHANGE_VERSION,
194
289
  kind: "bundle",
195
290
  generator: input.generator
196
291
  ? { ...input.generator }
197
- : { name: "@actuarial-ts/compliance", version: COMPLIANCE_PACKAGE_VERSION },
292
+ : {
293
+ name: "@actuarial-ts/compliance",
294
+ version: COMPLIANCE_PACKAGE_VERSION,
295
+ },
198
296
  createdAt: input.createdAt,
199
297
  bundle: bundleSegment,
200
298
  interchange,
@@ -229,18 +327,29 @@ export interface VerifyBundleResult {
229
327
  }
230
328
 
231
329
  function isPlainObject(value: unknown): value is Record<string, unknown> {
232
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
330
+ if (value === null || typeof value !== "object" || Array.isArray(value))
331
+ return false;
233
332
  const proto: unknown = Object.getPrototypeOf(value);
234
333
  return proto === Object.prototype || proto === null;
235
334
  }
236
335
 
237
336
  /** First differing path between two canonicalizable structures, or null when equal. */
238
- 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 {
239
342
  if (isPlainObject(stored) && isPlainObject(rerun)) {
240
- 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();
241
346
  for (const key of keys) {
242
347
  const keyPath = `${path}.${key}`;
243
- 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;
244
353
  const diff = firstDifference(stored[key], rerun[key], keyPath);
245
354
  if (diff !== null) return diff;
246
355
  }
@@ -258,7 +367,10 @@ function firstDifference(stored: unknown, rerun: unknown, path: string): string
258
367
  }
259
368
 
260
369
  /** Inner verification — the pre-wrapped behavior, unchanged byte for byte. */
261
- function verifyUnwrapped(bundle: ReproducibilityBundle, rerunResults: unknown): VerifyBundleResult {
370
+ function verifyUnwrapped(
371
+ bundle: ReproducibilityBundle,
372
+ rerunResults: unknown,
373
+ ): VerifyBundleResult {
262
374
  // The bundle's own hash first. Without this, the attestation ("these results
263
375
  // came from exactly these inputs, parameters, and SDK versions") checked the
264
376
  // results segment only — a bundle with rewritten inputs and hash "deadbeef"
@@ -274,21 +386,68 @@ function verifyUnwrapped(bundle: ReproducibilityBundle, rerunResults: unknown):
274
386
  throw new ComplianceError("BAD_BUNDLE", "bundle payload is not valid JSON");
275
387
  }
276
388
  if (!isPlainObject(stored) || !("results" in stored)) {
277
- 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
+ }
278
429
  }
279
430
  const storedResults = stored["results"];
280
431
  const rerunCanonical = canonicalJson(rerunResults);
281
432
  const storedCanonical = canonicalJson(storedResults);
282
433
  if (rerunCanonical === storedCanonical) return { reproduced: true };
283
- return { reproduced: false, mismatchPath: firstDifference(storedResults, rerunResults, "$") ?? "$" };
434
+ return {
435
+ reproduced: false,
436
+ mismatchPath: firstDifference(storedResults, rerunResults, "$") ?? "$",
437
+ };
284
438
  }
285
439
 
286
- function isWrappedBundleDoc(value: ReproducibilityBundle | WrappedBundleDoc): value is WrappedBundleDoc {
440
+ function isWrappedBundleDoc(
441
+ value: ReproducibilityBundle | WrappedBundleDoc,
442
+ ): value is WrappedBundleDoc {
287
443
  return (value as { kind?: unknown }).kind === "bundle";
288
444
  }
289
445
 
290
446
  /** Wrapped mode: the outer tag (spec 3.2) AND the inner bundle exactly as today. */
291
- function verifyWrapped(doc: WrappedBundleDoc, rerunResults: unknown): VerifyBundleResult {
447
+ function verifyWrapped(
448
+ doc: WrappedBundleDoc,
449
+ rerunResults: unknown,
450
+ ): VerifyBundleResult {
292
451
  const innerRaw: unknown = doc.bundle;
293
452
  if (
294
453
  !isPlainObject(innerRaw) ||
@@ -301,18 +460,104 @@ function verifyWrapped(doc: WrappedBundleDoc, rerunResults: unknown): VerifyBund
301
460
  );
302
461
  }
303
462
  if (doc.interchange === undefined || doc.interchange === null) {
304
- 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
+ );
305
467
  }
306
468
  // OUTER tag over the two-field semantic body { bundle, interchange } —
307
469
  // exactly interchange's semanticBodyOf for kind "bundle" (spec 3.2).
308
- const expected = fnv1a64(canonicalJson({ bundle: doc.bundle, interchange: doc.interchange }));
470
+ const expected = fnv1a64(
471
+ canonicalJson({ bundle: doc.bundle, interchange: doc.interchange }),
472
+ );
309
473
  const actual = typeof doc.integrity === "string" ? doc.integrity : null;
310
- const outerIntegrity: OuterIntegrityCheck = { ok: expected === actual, expected, actual };
474
+ const outerIntegrity: OuterIntegrityCheck = {
475
+ ok: expected === actual,
476
+ expected,
477
+ actual,
478
+ };
311
479
  if (!outerIntegrity.ok) {
312
480
  return { reproduced: false, mismatchPath: "$.integrity", outerIntegrity };
313
481
  }
314
- const inner: ReproducibilityBundle = { payload: innerRaw["payload"], hash: innerRaw["hash"] };
315
- 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 };
316
561
  }
317
562
 
318
563
  /**
@@ -337,5 +582,7 @@ export function verifyBundle(
337
582
  bundle: ReproducibilityBundle | WrappedBundleDoc,
338
583
  rerunResults: unknown,
339
584
  ): VerifyBundleResult {
340
- return isWrappedBundleDoc(bundle) ? verifyWrapped(bundle, rerunResults) : verifyUnwrapped(bundle, rerunResults);
585
+ return isWrappedBundleDoc(bundle)
586
+ ? verifyWrapped(bundle, rerunResults)
587
+ : verifyUnwrapped(bundle, rerunResults);
341
588
  }