@actuarial-ts/core 0.6.1 → 0.7.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 (63) hide show
  1. package/README.md +11 -2
  2. package/dist/canonical.d.ts +11 -0
  3. package/dist/canonical.d.ts.map +1 -1
  4. package/dist/canonical.js +136 -26
  5. package/dist/canonical.js.map +1 -1
  6. package/dist/diagnosticEvidenceIntern.d.ts +16 -0
  7. package/dist/diagnosticEvidenceIntern.d.ts.map +1 -0
  8. package/dist/diagnosticEvidenceIntern.js +139 -0
  9. package/dist/diagnosticEvidenceIntern.js.map +1 -0
  10. package/dist/diagnosticExposure.d.ts.map +1 -1
  11. package/dist/diagnosticExposure.js +97 -7
  12. package/dist/diagnosticExposure.js.map +1 -1
  13. package/dist/diagnosticIdentity.d.ts.map +1 -1
  14. package/dist/diagnosticIdentity.js +6 -3
  15. package/dist/diagnosticIdentity.js.map +1 -1
  16. package/dist/diagnosticIdentityStream.d.ts +56 -0
  17. package/dist/diagnosticIdentityStream.d.ts.map +1 -0
  18. package/dist/diagnosticIdentityStream.js +472 -0
  19. package/dist/diagnosticIdentityStream.js.map +1 -0
  20. package/dist/diagnosticPreparation.d.ts +28 -0
  21. package/dist/diagnosticPreparation.d.ts.map +1 -1
  22. package/dist/diagnosticPreparation.js +160 -36
  23. package/dist/diagnosticPreparation.js.map +1 -1
  24. package/dist/diagnosticReview.d.ts +4 -1
  25. package/dist/diagnosticReview.d.ts.map +1 -1
  26. package/dist/diagnosticReview.js +203 -51
  27. package/dist/diagnosticReview.js.map +1 -1
  28. package/dist/diagnosticReviewSources.d.ts +18 -0
  29. package/dist/diagnosticReviewSources.d.ts.map +1 -0
  30. package/dist/diagnosticReviewSources.js +76 -0
  31. package/dist/diagnosticReviewSources.js.map +1 -0
  32. package/dist/diagnosticReviewStore.d.ts +88 -0
  33. package/dist/diagnosticReviewStore.d.ts.map +1 -0
  34. package/dist/diagnosticReviewStore.js +455 -0
  35. package/dist/diagnosticReviewStore.js.map +1 -0
  36. package/dist/diagnosticRunner.d.ts +25 -1
  37. package/dist/diagnosticRunner.d.ts.map +1 -1
  38. package/dist/diagnosticRunner.js +115 -7
  39. package/dist/diagnosticRunner.js.map +1 -1
  40. package/dist/fnvAccumulator.d.ts +6 -0
  41. package/dist/fnvAccumulator.d.ts.map +1 -0
  42. package/dist/fnvAccumulator.js +21 -0
  43. package/dist/fnvAccumulator.js.map +1 -0
  44. package/dist/index.d.ts +8 -5
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +5 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/version.d.ts +1 -1
  49. package/dist/version.js +1 -1
  50. package/package.json +1 -1
  51. package/src/canonical.ts +144 -26
  52. package/src/diagnosticEvidenceIntern.ts +151 -0
  53. package/src/diagnosticExposure.ts +126 -17
  54. package/src/diagnosticIdentity.ts +15 -6
  55. package/src/diagnosticIdentityStream.ts +553 -0
  56. package/src/diagnosticPreparation.ts +268 -84
  57. package/src/diagnosticReview.ts +293 -85
  58. package/src/diagnosticReviewSources.ts +85 -0
  59. package/src/diagnosticReviewStore.ts +692 -0
  60. package/src/diagnosticRunner.ts +206 -9
  61. package/src/fnvAccumulator.ts +20 -0
  62. package/src/index.ts +38 -1
  63. package/src/version.ts +1 -1
package/src/canonical.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ReservingError } from "./types.js";
2
+ import { createFnvAccumulator } from "./fnvAccumulator.js";
2
3
 
3
4
  /**
4
5
  * Canonical JSON serialization and the FNV-1a integrity hash — the SDK's
@@ -34,18 +35,35 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
34
35
  return proto === Object.prototype || proto === null;
35
36
  }
36
37
 
37
- function canonicalize(value: unknown, path: string, seen: Set<object>): string {
38
- if (value === null) return "null";
38
+ interface CanonicalSink {
39
+ write(text: string): void;
40
+ quote(text: string): void;
41
+ startContainer(token: "[" | "{"): void;
42
+ endContainer(token: "]" | "}"): void;
43
+ }
44
+
45
+ /** One traversal owns validation and property access order for both outputs. */
46
+ function canonicalize(value: unknown, path: string, seen: Set<object>, sink: CanonicalSink): void {
47
+ if (value === null) {
48
+ sink.write("null");
49
+ return;
50
+ }
39
51
  switch (typeof value) {
40
52
  case "string":
41
- return JSON.stringify(value);
53
+ sink.quote(value);
54
+ return;
42
55
  case "boolean":
43
- return value ? "true" : "false";
56
+ sink.write(value ? "true" : "false");
57
+ return;
44
58
  case "number": {
45
59
  if (!Number.isFinite(value)) {
46
- throw new ReservingError("UNSUPPORTED_VALUE", `non-finite number (${String(value)}) at ${path}`);
60
+ throw new ReservingError(
61
+ "UNSUPPORTED_VALUE",
62
+ `non-finite number (${String(value)}) at ${path}`,
63
+ );
47
64
  }
48
- return Object.is(value, -0) ? "0" : String(value);
65
+ sink.write(Object.is(value, -0) ? "0" : String(value));
66
+ return;
49
67
  }
50
68
  case "undefined":
51
69
  throw new ReservingError("UNSUPPORTED_VALUE", `undefined at ${path}`);
@@ -62,20 +80,24 @@ function canonicalize(value: unknown, path: string, seen: Set<object>): string {
62
80
  throw new ReservingError("UNSUPPORTED_VALUE", `circular reference at ${path}`);
63
81
  }
64
82
  seen.add(obj);
65
- let out: string;
66
83
  if (Array.isArray(obj)) {
67
- const parts: string[] = [];
84
+ sink.startContainer("[");
68
85
  for (let i = 0; i < obj.length; i++) {
69
- parts.push(canonicalize(obj[i], `${path}[${i}]`, seen));
86
+ if (i > 0) sink.write(",");
87
+ canonicalize(obj[i], `${path}[${i}]`, seen, sink);
70
88
  }
71
- out = `[${parts.join(",")}]`;
89
+ sink.endContainer("]");
72
90
  } else if (isPlainObject(obj)) {
73
91
  const keys = Object.keys(obj).sort();
74
- const parts: string[] = [];
75
- for (const key of keys) {
76
- parts.push(`${JSON.stringify(key)}:${canonicalize(obj[key], `${path}.${key}`, seen)}`);
92
+ sink.startContainer("{");
93
+ for (let i = 0; i < keys.length; i++) {
94
+ if (i > 0) sink.write(",");
95
+ const key = keys[i]!;
96
+ sink.quote(key);
97
+ sink.write(":");
98
+ canonicalize(obj[key], `${path}.${key}`, seen, sink);
77
99
  }
78
- out = `{${parts.join(",")}}`;
100
+ sink.endContainer("}");
79
101
  } else {
80
102
  const name = (obj.constructor as { name?: string } | undefined)?.name ?? "unknown";
81
103
  throw new ReservingError(
@@ -84,7 +106,6 @@ function canonicalize(value: unknown, path: string, seen: Set<object>): string {
84
106
  );
85
107
  }
86
108
  seen.delete(obj);
87
- return out;
88
109
  }
89
110
 
90
111
  /**
@@ -95,12 +116,59 @@ function canonicalize(value: unknown, path: string, seen: Set<object>): string {
95
116
  * "$.rows[2].ultimate" — for any value JSON cannot faithfully represent.
96
117
  */
97
118
  export function canonicalJson(value: unknown): string {
98
- return canonicalize(value, "$", new Set());
119
+ // Join finished subtrees as before, rather than retaining a flat token array
120
+ // for the entire input graph alongside the final serialized string.
121
+ const frames: string[][] = [[]];
122
+ const write = (text: string) => {
123
+ frames[frames.length - 1]!.push(text);
124
+ };
125
+ canonicalize(value, "$", new Set(), {
126
+ write,
127
+ quote(text) {
128
+ write(JSON.stringify(text));
129
+ },
130
+ startContainer(token) {
131
+ frames.push([token]);
132
+ },
133
+ endContainer(token) {
134
+ const parts = frames.pop()!;
135
+ parts.push(token);
136
+ write(parts.join(""));
137
+ },
138
+ });
139
+ return frames[0]!.join("");
99
140
  }
100
141
 
101
- const FNV_OFFSET_BASIS = 0xcbf29ce484222325n;
102
- const FNV_PRIME = 0x100000001b3n;
103
- const MASK_64 = 0xffffffffffffffffn;
142
+ const UTF8_CHUNK_CODE_UNITS = 16_384;
143
+ const QUOTED_CHUNK_CODE_UNITS = 4_096;
144
+
145
+ /** A valid surrogate pair must reach native escaping/encoding together. */
146
+ function chunkEnd(text: string, start: number, limit: number): number {
147
+ let end = Math.min(start + limit, text.length);
148
+ const before = text.charCodeAt(end - 1);
149
+ const after = text.charCodeAt(end);
150
+ if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) end -= 1;
151
+ return end;
152
+ }
153
+
154
+ /** Reuse at most 48 KiB for long strings instead of duplicating all UTF-8 bytes. */
155
+ function* utf8Chunks(text: string): Generator<Uint8Array> {
156
+ const encoder = new TextEncoder();
157
+ // Retain TextEncoder's runtime coercion for untyped JavaScript callers.
158
+ if (typeof text !== "string" || text.length <= UTF8_CHUNK_CODE_UNITS) {
159
+ yield encoder.encode(text);
160
+ return;
161
+ }
162
+ // A UTF-16 code unit needs at most three UTF-8 bytes (a surrogate pair
163
+ // needs four bytes for two code units), so encodeInto always consumes a chunk.
164
+ const buffer = new Uint8Array(UTF8_CHUNK_CODE_UNITS * 3);
165
+ for (let start = 0; start < text.length; ) {
166
+ const end = chunkEnd(text, start, UTF8_CHUNK_CODE_UNITS);
167
+ const { written } = encoder.encodeInto(text.slice(start, end), buffer);
168
+ yield buffer.subarray(0, written);
169
+ start = end;
170
+ }
171
+ }
104
172
 
105
173
  /**
106
174
  * FNV-1a 64-bit hash over the UTF-8 bytes of `text`, returned as a 16-hex-char
@@ -112,11 +180,61 @@ const MASK_64 = 0xffffffffffffffffn;
112
180
  * needing tamper evidence must sign or cryptographically hash the payload.
113
181
  */
114
182
  export function fnv1a64(text: string): string {
115
- const bytes = new TextEncoder().encode(text);
116
- let hash = FNV_OFFSET_BASIS;
117
- for (const byte of bytes) {
118
- hash ^= BigInt(byte);
119
- hash = (hash * FNV_PRIME) & MASK_64;
120
- }
121
- return hash.toString(16).padStart(16, "0");
183
+ const hash = createFnvAccumulator();
184
+ for (const bytes of utf8Chunks(text)) hash.update(bytes);
185
+ return hash.digest();
186
+ }
187
+
188
+ /**
189
+ * The exact integrity tag `fnv1a64(canonicalJson(value))`, without constructing
190
+ * the complete canonical string or UTF-8 byte array. Validation, key ordering,
191
+ * escaping and unsupported-value error paths match `canonicalJson`.
192
+ *
193
+ * Text and byte buffers are bounded, including for a single large string/key;
194
+ * object-key sorting, ancestor tracking and paths still scale with input shape.
195
+ * The caller's input remains in memory. This is not a constant-memory dataset
196
+ * pipeline, a collision-resistant hash, or a substitute for exact equality.
197
+ */
198
+ export function canonicalFnv1a64(value: unknown): string {
199
+ const hash = createFnvAccumulator();
200
+ const encoder = new TextEncoder();
201
+ const bytes = new Uint8Array(UTF8_CHUNK_CODE_UNITS * 3);
202
+ let buffered = "";
203
+ const flush = () => {
204
+ if (!buffered.length) return;
205
+ // Three bytes per UTF-16 code unit always fit; pairs need only four for two.
206
+ const { written } = encoder.encodeInto(buffered, bytes);
207
+ hash.update(bytes.subarray(0, written));
208
+ buffered = "";
209
+ };
210
+ const write = (text: string) => {
211
+ for (let start = 0; start < text.length; ) {
212
+ const end = chunkEnd(text, start, UTF8_CHUNK_CODE_UNITS - buffered.length);
213
+ if (end === start) {
214
+ flush();
215
+ continue;
216
+ }
217
+ buffered += text.slice(start, end);
218
+ start = end;
219
+ if (buffered.length >= UTF8_CHUNK_CODE_UNITS - 1) flush();
220
+ }
221
+ };
222
+ canonicalize(value, "$", new Set(), {
223
+ write,
224
+ startContainer: write,
225
+ endContainer: write,
226
+ quote(text) {
227
+ write('"');
228
+ for (let start = 0; start < text.length; ) {
229
+ const end = chunkEnd(text, start, QUOTED_CHUNK_CODE_UNITS);
230
+ // Bounded native escaping retains minimal JSON escape spelling and lone
231
+ // surrogate escapes without allocating one full escaped string atom.
232
+ write(JSON.stringify(text.slice(start, end)).slice(1, -1));
233
+ start = end;
234
+ }
235
+ write('"');
236
+ },
237
+ });
238
+ flush();
239
+ return hash.digest();
122
240
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Private, invocation-scoped sharing of small, newly owned evidence nodes.
3
+ * This is not a validation boundary: callers must finish normalizing/cloning
4
+ * every child first, and must never submit caller-owned objects or SDK brands.
5
+ */
6
+ export interface DiagnosticEvidenceInternOptions {
7
+ readonly maxEntries?: number;
8
+ readonly maxSignatureCharacters?: number;
9
+ readonly maxCandidateProperties?: number;
10
+ }
11
+
12
+ type EvidenceMode = "plain" | "source" | "free";
13
+
14
+ const DEFAULT_MAX_ENTRIES = 100_000;
15
+ const DEFAULT_MAX_SIGNATURE_CHARACTERS = 16 * 1024 * 1024;
16
+ // Wide result envelopes are usually unique; focus the budget on their small
17
+ // repeated coordinates, sources, scopes, states, and short child lists.
18
+ const DEFAULT_MAX_CANDIDATE_PROPERTIES = 10;
19
+ const MAX_CANDIDATE_STRING_CHARACTERS = 4096;
20
+
21
+ function sameOwnedNode(left: object, right: object): boolean {
22
+ if (Object.getPrototypeOf(left) !== Object.getPrototypeOf(right))
23
+ return false;
24
+ const leftKeys = Reflect.ownKeys(left);
25
+ const rightKeys = Reflect.ownKeys(right);
26
+ if (leftKeys.length !== rightKeys.length) return false;
27
+ return leftKeys.every((key, index) => {
28
+ if (key !== rightKeys[index]) return false;
29
+ const a = Object.getOwnPropertyDescriptor(left, key)!;
30
+ const b = Object.getOwnPropertyDescriptor(right, key)!;
31
+ return (
32
+ "value" in a &&
33
+ "value" in b &&
34
+ a.enumerable === b.enumerable &&
35
+ a.configurable === b.configurable &&
36
+ a.writable === b.writable &&
37
+ Object.is(a.value, b.value)
38
+ );
39
+ });
40
+ }
41
+
42
+ export function createDiagnosticEvidenceInterner(
43
+ options: DiagnosticEvidenceInternOptions = {},
44
+ ): {
45
+ internOwned<T extends object>(candidate: T, mode: EvidenceMode): T;
46
+ } {
47
+ const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
48
+ const maxSignatureCharacters =
49
+ options.maxSignatureCharacters ?? DEFAULT_MAX_SIGNATURE_CHARACTERS;
50
+ const maxCandidateProperties =
51
+ options.maxCandidateProperties ?? DEFAULT_MAX_CANDIDATE_PROPERTIES;
52
+ const pool = new Map<string, object>();
53
+ const childIds = new WeakMap<object, number>();
54
+ let nextChildId = 1;
55
+ let signatureCharacters = 0;
56
+
57
+ return {
58
+ internOwned<T extends object>(candidate: T, mode: EvidenceMode): T {
59
+ if (!Object.isFrozen(candidate)) return candidate;
60
+ const prototype = Object.getPrototypeOf(candidate);
61
+ const array = Array.isArray(candidate);
62
+ if (
63
+ array
64
+ ? prototype !== Array.prototype
65
+ : prototype !== null && prototype !== Object.prototype
66
+ )
67
+ return candidate;
68
+ if (array && candidate.length >= maxCandidateProperties) return candidate;
69
+ const keys = Reflect.ownKeys(candidate);
70
+ if (keys.length > maxCandidateProperties) return candidate;
71
+ const signatureParts: unknown[] = [
72
+ mode,
73
+ array ? "array" : prototype === null ? "null" : "object",
74
+ ];
75
+ // A declined candidate must not grow persistent bookkeeping. Stage at
76
+ // most this small candidate's children, including repeated references,
77
+ // and commit their IDs only if the candidate enters the bounded pool.
78
+ let pendingChildIds: Map<object, number> | undefined;
79
+ let stringCharacters = 0;
80
+ for (const key of keys) {
81
+ // Diagnostic JSON does not contain symbols. Declining unexpected
82
+ // shapes is harmless and never invokes an accessor or caller method.
83
+ if (typeof key !== "string") return candidate;
84
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key)!;
85
+ if (!("value" in descriptor)) return candidate;
86
+ const value: unknown = descriptor.value;
87
+ let tokenType: string;
88
+ let tokenValue: unknown;
89
+ if (value === null) {
90
+ tokenType = "null";
91
+ tokenValue = null;
92
+ } else if (typeof value === "object") {
93
+ if (!Object.isFrozen(value)) return candidate;
94
+ tokenType = "object";
95
+ const existingId = childIds.get(value);
96
+ if (existingId !== undefined) tokenValue = existingId;
97
+ else {
98
+ pendingChildIds ??= new Map();
99
+ let pendingId = pendingChildIds.get(value);
100
+ if (pendingId === undefined) {
101
+ pendingId = nextChildId + pendingChildIds.size;
102
+ pendingChildIds.set(value, pendingId);
103
+ }
104
+ tokenValue = pendingId;
105
+ }
106
+ } else if (typeof value === "number") {
107
+ if (!Number.isFinite(value)) return candidate;
108
+ tokenType = "number";
109
+ tokenValue = Object.is(value, -0) ? "-0" : value;
110
+ } else if (
111
+ typeof value === "string" ||
112
+ typeof value === "boolean" ||
113
+ value === undefined
114
+ ) {
115
+ tokenType = typeof value;
116
+ tokenValue = value;
117
+ if (typeof value === "string") stringCharacters += value.length;
118
+ } else return candidate;
119
+ stringCharacters += key.length;
120
+ if (stringCharacters > MAX_CANDIDATE_STRING_CHARACTERS)
121
+ return candidate;
122
+ // Frozen data properties are necessarily non-configurable and
123
+ // non-writable. Keep the varying enumerability bit (including array
124
+ // length) and a fixed four-slot typed tuple, without temporary nested
125
+ // arrays. The independent equality check still verifies every flag.
126
+ signatureParts.push(key, descriptor.enumerable, tokenType, tokenValue);
127
+ }
128
+ const signature = JSON.stringify(signatureParts);
129
+ // An unseen child cannot occur in an existing entry: every admitted
130
+ // entry committed all its child IDs. No equality hit is skipped here.
131
+ const existing = pendingChildIds ? undefined : pool.get(signature);
132
+ // Signatures encode exact children and descriptor/order information;
133
+ // independently check equality before sharing rather than treating a
134
+ // fingerprint as proof of equality.
135
+ if (existing !== undefined && sameOwnedNode(existing, candidate))
136
+ return existing as T;
137
+ if (
138
+ pool.size < maxEntries &&
139
+ signatureCharacters + signature.length <= maxSignatureCharacters
140
+ ) {
141
+ if (pendingChildIds) {
142
+ for (const [child, id] of pendingChildIds) childIds.set(child, id);
143
+ nextChildId += pendingChildIds.size;
144
+ }
145
+ pool.set(signature, candidate);
146
+ signatureCharacters += signature.length;
147
+ }
148
+ return candidate;
149
+ },
150
+ };
151
+ }
@@ -14,12 +14,119 @@ import {
14
14
  } from "./types.js";
15
15
  import {
16
16
  diagnosticJsonPreflight,
17
+ diagnosticRecord,
17
18
  hasDiagnosticOwn,
18
19
  isDiagnosticPlainRecord,
19
20
  isDiagnosticToken,
20
21
  snapshotDiagnosticJson,
22
+ MAX_DIAGNOSTIC_JSON_DEPTH,
21
23
  } from "./diagnosticRuntime.js";
22
24
 
25
+ // Bound the collection separately from each untrusted record. The generic
26
+ // million-node JSON cap otherwise rejects legitimate files at ~71k–100k rows,
27
+ // depending only on how much source provenance each observation carries.
28
+ const MAX_EXPOSURE_OBSERVATIONS = 250_000;
29
+
30
+ // JSON enumeration skips hidden properties, but the strict exposure schema
31
+ // reads recognized own fields. Refuse hidden accessors before those reads.
32
+ function hiddenExposureAccessorIssues(
33
+ value: unknown,
34
+ path: string,
35
+ ): DiagnosticValidationIssue[] {
36
+ if (!isDiagnosticPlainRecord(value)) return [];
37
+ const issues: DiagnosticValidationIssue[] = [];
38
+ for (const key of Object.getOwnPropertyNames(value)) {
39
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
40
+ if (!descriptor.enumerable && !("value" in descriptor))
41
+ issues.push({
42
+ domain: "input",
43
+ code: "invalid-json-value",
44
+ path: propertyPath(path, key),
45
+ message: "JSON objects may contain only data properties",
46
+ });
47
+ }
48
+ return issues;
49
+ }
50
+
51
+ function exposureCollectionPreflight(
52
+ value: unknown,
53
+ ): readonly DiagnosticValidationIssue[] {
54
+ if (!Array.isArray(value)) return diagnosticJsonPreflight(value, "input");
55
+ if (Object.getPrototypeOf(value) !== Array.prototype)
56
+ return [
57
+ {
58
+ domain: "input",
59
+ code: "invalid-json-value",
60
+ path: "$",
61
+ message: "Value must use a plain object or array prototype",
62
+ },
63
+ ];
64
+ if (value.length > MAX_EXPOSURE_OBSERVATIONS)
65
+ return [
66
+ {
67
+ domain: "input",
68
+ code: "expression-limit",
69
+ path: "$",
70
+ message: `Exposure observation count exceeds ${MAX_EXPOSURE_OBSERVATIONS}`,
71
+ },
72
+ ];
73
+ const issues: DiagnosticValidationIssue[] = [];
74
+ for (const key of Reflect.ownKeys(value)) {
75
+ if (key === "length") continue;
76
+ const index = typeof key === "string" ? Number(key) : Number.NaN;
77
+ if (
78
+ Number.isInteger(index) &&
79
+ index >= 0 &&
80
+ index < value.length &&
81
+ String(index) === key
82
+ )
83
+ continue;
84
+ issues.push({
85
+ domain: "input",
86
+ code: "invalid-json-value",
87
+ path: typeof key === "symbol" ? "$" : propertyPath("$", key),
88
+ message: "JSON arrays may contain only indexed data properties",
89
+ });
90
+ }
91
+ for (let index = 0; index < value.length; index++) {
92
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
93
+ if (!descriptor || !("value" in descriptor)) {
94
+ issues.push({
95
+ domain: "input",
96
+ code: "invalid-json-value",
97
+ path: `$[${index}]`,
98
+ message: "JSON arrays may contain only indexed data properties",
99
+ });
100
+ continue;
101
+ }
102
+ issues.push(
103
+ ...hiddenExposureAccessorIssues(descriptor.value, `$[${index}]`),
104
+ );
105
+ if (isDiagnosticPlainRecord(descriptor.value)) {
106
+ const source = Object.getOwnPropertyDescriptor(
107
+ descriptor.value,
108
+ "source",
109
+ );
110
+ if (source && "value" in source)
111
+ issues.push(
112
+ ...hiddenExposureAccessorIssues(source.value, `$[${index}].source`),
113
+ );
114
+ }
115
+ // The outer collection used to occupy depth 1: keep the exact depth
116
+ // contract, and retain the default million-node guard for each record.
117
+ for (const issue of diagnosticJsonPreflight(descriptor.value, "input", {
118
+ maxDepth: MAX_DIAGNOSTIC_JSON_DEPTH - 1,
119
+ }))
120
+ issues.push({ ...issue, path: `$[${index}]${issue.path.slice(1)}` });
121
+ }
122
+ return issues;
123
+ }
124
+
125
+ /** Only for new SDK-owned objects whose leaves have already been validated. */
126
+ function freezeExposureRecord<T extends object>(value: T): T {
127
+ return Object.freeze(Object.assign(diagnosticRecord<unknown>(), value)) as T;
128
+ }
129
+
23
130
  export interface DiagnosticExposureObservation {
24
131
  readonly key: string;
25
132
  readonly sourceGroup: string;
@@ -115,7 +222,7 @@ function validateExposureArguments(
115
222
  // Nonfinite exposure amounts are supported audited inputs. They are the
116
223
  // only non-JSON numbers permitted here; metadata still must be finite.
117
224
  const issues: DiagnosticValidationIssue[] = [
118
- ...diagnosticJsonPreflight(observations, "input")
225
+ ...exposureCollectionPreflight(observations)
119
226
  .filter(
120
227
  (issue) =>
121
228
  !(
@@ -396,7 +503,7 @@ export function reconcileDiagnosticExposures(
396
503
  cohort.push(observation);
397
504
  cohorts.set(identity, cohort);
398
505
  }
399
- return snapshotDiagnosticJson(
506
+ return Object.freeze(
400
507
  [...cohorts.values()]
401
508
  .map((cohort): ReconciledDiagnosticExposure => {
402
509
  const first = cohort[0]!;
@@ -404,18 +511,20 @@ export function reconcileDiagnosticExposures(
404
511
  ? timingByMeasure[first.measureId]
405
512
  : undefined;
406
513
  const audited = cohort
407
- .map((item): DiagnosticExposureAuditObservation => ({
408
- sourceGroup: item.sourceGroup,
409
- origin: item.origin,
410
- ...(item.valuation === undefined
411
- ? {}
412
- : { valuation: item.valuation }),
413
- value: auditDiagnosticNumber(item.value),
414
- complete: item.complete,
415
- ...(item.source === undefined
416
- ? {}
417
- : { source: Object.freeze({ ...item.source }) }),
418
- }))
514
+ .map((item): DiagnosticExposureAuditObservation =>
515
+ snapshotDiagnosticJson({
516
+ sourceGroup: item.sourceGroup,
517
+ origin: item.origin,
518
+ ...(item.valuation === undefined
519
+ ? {}
520
+ : { valuation: item.valuation }),
521
+ value: auditDiagnosticNumber(item.value),
522
+ complete: item.complete,
523
+ ...(item.source === undefined
524
+ ? {}
525
+ : { source: Object.freeze({ ...item.source }) }),
526
+ }),
527
+ )
419
528
  .sort(observationOrder);
420
529
  const issues: (
421
530
  "missing" | "incomplete" | "non-finite" | "duplicate" | "conflict"
@@ -432,7 +541,7 @@ export function reconcileDiagnosticExposures(
432
541
  const validStaticCopies =
433
542
  timing === "origin-static" && issues.length === 0;
434
543
  if (issues.length > 0)
435
- return Object.freeze({
544
+ return freezeExposureRecord({
436
545
  measureId: first.measureId,
437
546
  key: first.key,
438
547
  status: "invalid",
@@ -445,8 +554,8 @@ export function reconcileDiagnosticExposures(
445
554
  throw new Error("unreachable invalid exposure state");
446
555
  const sources = normalizeDiagnosticSourceLocations(
447
556
  audited.map((item) => item.source),
448
- );
449
- return Object.freeze({
557
+ ).map((source) => snapshotDiagnosticJson(source));
558
+ return freezeExposureRecord({
450
559
  measureId: first.measureId,
451
560
  key: first.key,
452
561
  status: "valid",
@@ -1,4 +1,5 @@
1
1
  import { canonicalJson, fnv1a64 } from "./canonical.js";
2
+ import { createDiagnosticEvidenceInterner } from "./diagnosticEvidenceIntern.js";
2
3
  import type {
3
4
  AmountBasisDefinition,
4
5
  AmountLimitation,
@@ -181,6 +182,7 @@ export function projectDiagnosticIdentity<T>(
181
182
  maxNodes: Number.MAX_SAFE_INTEGER,
182
183
  });
183
184
  if (issues.length) throw new DiagnosticValidationError(issues);
185
+ const sharing = createDiagnosticEvidenceInterner();
184
186
  const clone = (
185
187
  item: unknown,
186
188
  sourceSlot = false,
@@ -189,11 +191,15 @@ export function projectDiagnosticIdentity<T>(
189
191
  ): unknown => {
190
192
  if (item === null || typeof item !== "object")
191
193
  return typeof item === "number" && Object.is(item, -0) ? 0 : item;
194
+ const mode = freeJson ? "free" : sourceSlot ? "source" : "plain";
192
195
  if (Array.isArray(item))
193
- return Object.freeze(
194
- item.map((child, index) =>
195
- clone(child, sourceSlot, freeJson, `${path}[${index}]`),
196
+ return sharing.internOwned(
197
+ Object.freeze(
198
+ item.map((child, index) =>
199
+ clone(child, sourceSlot, freeJson, `${path}[${index}]`),
200
+ ),
196
201
  ),
202
+ mode,
197
203
  );
198
204
  const record = item as Record<string, unknown>;
199
205
  if (sourceSlot && typeof record.artifactId === "string") {
@@ -243,8 +249,11 @@ export function projectDiagnosticIdentity<T>(
243
249
  }
244
250
  if (sourceIssues.length)
245
251
  throw new DiagnosticValidationError(sourceIssues);
246
- return normalizeDiagnosticSourceLocation(
247
- record as unknown as DiagnosticSourceLocation,
252
+ return sharing.internOwned(
253
+ normalizeDiagnosticSourceLocation(
254
+ record as unknown as DiagnosticSourceLocation,
255
+ ),
256
+ mode,
248
257
  );
249
258
  }
250
259
  const result = Object.create(null) as Record<string, unknown>;
@@ -258,7 +267,7 @@ export function projectDiagnosticIdentity<T>(
258
267
  `${path}.${key}`,
259
268
  );
260
269
  }
261
- return Object.freeze(result);
270
+ return sharing.internOwned(Object.freeze(result), mode);
262
271
  };
263
272
  return clone(value) as DiagnosticIdentityProjection<T>;
264
273
  }