@slicedearth/whoisleuth-cli 1.47.4 → 2.0.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 (235) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/provider-policy-admission.mjs +3 -1
  100. package/lib/rdap-bootstrap.mjs +76 -16
  101. package/lib/rdap-client.mjs +20 -12
  102. package/lib/rdap-normalization.mjs +306 -75
  103. package/lib/rdap-transport.mjs +2 -2
  104. package/lib/rdap-validation.mjs +65 -14
  105. package/lib/rdap.mjs +2 -2
  106. package/lib/registrable-domain.mjs +36 -0
  107. package/lib/registry-insights.mjs +7 -0
  108. package/lib/response-policy.mjs +27 -4
  109. package/lib/risk-calibration-summary.mjs +35 -33
  110. package/lib/risk-scoring.mjs +4 -3
  111. package/lib/safe-fetch.mjs +62 -40
  112. package/lib/security-txt.mjs +1 -1
  113. package/lib/smtp-transport-review.mjs +8 -5
  114. package/lib/sslbl-intelligence.mjs +6 -8
  115. package/lib/static-html-analysis.mjs +301 -4
  116. package/lib/structured-data-identity.mjs +51 -19
  117. package/lib/threat-intelligence-fixtures.mjs +2 -4
  118. package/lib/threat-intelligence-manifests.mjs +2 -7
  119. package/lib/threat-intelligence-runtime.mjs +3 -5
  120. package/lib/threat-intelligence-types.mjs +3 -0
  121. package/lib/threatfox-intelligence.mjs +7 -3
  122. package/lib/tls-intelligence.mjs +4 -4
  123. package/lib/urlhaus-intelligence.mjs +4 -2
  124. package/lib/urlscan-intelligence.mjs +6 -5
  125. package/lib/website-security-posture.mjs +1 -1
  126. package/lib/website-technology.mjs +1 -1
  127. package/lib/whois-authority.mjs +43 -8
  128. package/lib/whois-chain.mjs +25 -3
  129. package/lib/whois-normalization.mjs +3 -1
  130. package/lib/whois-transport.mjs +12 -3
  131. package/lib/zone-intent-review.mjs +1 -1
  132. package/package.json +12 -4
  133. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  134. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  135. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  136. package/packages/cases/case-migration-model.mjs +476 -0
  137. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  138. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  139. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  140. package/packages/cases/case-record-model.mjs +9 -0
  141. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  142. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  143. package/packages/cases/case-response-model.mjs +1604 -0
  144. package/packages/cases/case-response-packet.mjs +1312 -0
  145. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  146. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  147. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  148. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  149. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  150. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  151. package/packages/comparison/favicon-similarity.mjs +78 -0
  152. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  153. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  154. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  155. package/packages/contracts/analyst-review-state.mjs +50 -0
  156. package/packages/contracts/case-portability.mjs +1310 -0
  157. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  158. package/packages/contracts/cli-lookup.mjs +394 -0
  159. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  160. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  161. package/packages/contracts/domain-control-monitor.mjs +310 -0
  162. package/packages/contracts/domain-control-review.mjs +828 -0
  163. package/packages/contracts/domain-name.mjs +4 -0
  164. package/packages/contracts/external-observation-interchange.mjs +224 -0
  165. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  166. package/packages/contracts/investigation-portability.mjs +331 -0
  167. package/packages/contracts/offline-comparison.mjs +160 -0
  168. package/packages/contracts/risk-calibration.mjs +1288 -0
  169. package/packages/contracts/schema-compatibility.mjs +71 -0
  170. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  171. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  172. package/packages/contracts/workspace-portability.mjs +1347 -0
  173. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  174. package/packages/evidence/decision-fact.mjs +735 -0
  175. package/packages/evidence/domain-control-runtime.mjs +547 -0
  176. package/packages/evidence/domain-name.mjs +41 -0
  177. package/packages/evidence/observation.mjs +384 -0
  178. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  179. package/packages/interchange/retained-text.mjs +8 -0
  180. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  181. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  182. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  183. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  184. package/packages/investigation/bulk-review-policy.mjs +1 -0
  185. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  186. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  187. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  188. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  189. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  190. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  191. package/packages/monitoring/analyst-review-state.mjs +680 -0
  192. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +70 -47
  193. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  194. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  195. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  196. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  197. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  198. package/packages/workspace/hostile-input.mjs +172 -0
  199. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  200. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  201. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  202. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  203. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  204. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  205. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  206. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  207. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  208. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  209. package/third-party-notices.txt +14 -14
  210. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  211. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  212. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  213. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  214. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  215. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  216. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  217. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  218. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  219. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  220. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  221. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  222. package/frontend/src/lib/analysis/scoring.js +0 -55
  223. package/frontend/src/lib/analysis/utils.js +0 -255
  224. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  225. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  226. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  227. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  228. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  229. package/lib/bounded-json-response.mjs +0 -170
  230. package/lib/lookup-response-contract.mjs +0 -1034
  231. package/lib/observation.mjs +0 -115
  232. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  233. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -0,0 +1,680 @@
1
+ /**
2
+ * Canonical, framework-independent analyst review identity and lifecycle.
3
+ *
4
+ * Review state is an analyst-authored overlay on retained evidence. It never
5
+ * rewrites the evidence that produced a Review Item and it never treats
6
+ * missing, partial, stale, or unavailable evidence as resolution.
7
+ */
8
+ import { ANALYST_REVIEW_DISPOSITIONS, ANALYST_REVIEW_EVIDENCE_FAMILIES, ANALYST_REVIEW_STATE_SCHEMA, ANALYST_REVIEW_STATE_SCHEMA_VERSION, MAX_ANALYST_REVIEW_ASSOCIATIONS, MAX_ANALYST_REVIEW_HISTORY, MAX_ANALYST_REVIEW_IDENTITY_BYTES, MAX_ANALYST_REVIEW_ITEMS, MAX_ANALYST_REVIEW_RATIONALE_LENGTH, MAX_ANALYST_REVIEW_STATE_BYTES, MAX_ANALYST_REVIEW_STATE_RECORDS, } from "../contracts/analyst-review-state-contract.mjs";
9
+ export * from "../contracts/analyst-review-state-contract.mjs";
10
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
11
+ const SAFE_REFERENCE_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
12
+ const SAFE_SUBJECT_RE = /^review:[a-z0-9_]{1,40}:[a-f0-9]{64}$/u;
13
+ const SAFE_FINGERPRINT_RE = /^material:[a-f0-9]{64}$/u;
14
+ const DEVELOPMENT_SUBJECT_RE = /^review:([a-z0-9_]{1,40}):[a-f0-9]{16}$/u;
15
+ const DEVELOPMENT_FINGERPRINT_RE = /^material:[a-f0-9]{16}$/u;
16
+ const FAMILY_VALUES = new Set(ANALYST_REVIEW_EVIDENCE_FAMILIES);
17
+ const DISPOSITION_VALUES = new Set(ANALYST_REVIEW_DISPOSITIONS);
18
+ const RECORD_KEYS = Object.freeze([
19
+ 'subjectKey', 'reviewedFingerprint', 'evidenceFamily', 'disposition', 'rationale',
20
+ 'reviewedAt', 'reviewDueAt', 'expiresAt', 'caseIds', 'campaignIds', 'history', 'historyOmitted',
21
+ ]);
22
+ const DEVELOPMENT_RECORD_KEYS = Object.freeze(RECORD_KEYS.filter((key) => key !== 'historyOmitted'));
23
+ const SNAPSHOT_KEYS = Object.freeze([
24
+ 'reviewedFingerprint', 'disposition', 'rationale', 'reviewedAt', 'reviewDueAt', 'expiresAt',
25
+ ]);
26
+ const STORE_KEYS = Object.freeze(['schema', 'version', 'records']);
27
+ function inputError(detail) {
28
+ return new TypeError(`Analyst review state must be bounded ordinary JSON data; ${detail}.`);
29
+ }
30
+ function ordinaryRecord(value, label) {
31
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
32
+ throw inputError(`${label} must be an object`);
33
+ }
34
+ let prototype;
35
+ let keys;
36
+ try {
37
+ prototype = Object.getPrototypeOf(value);
38
+ keys = Reflect.ownKeys(value);
39
+ }
40
+ catch {
41
+ throw inputError(`${label} could not be inspected safely`);
42
+ }
43
+ if (prototype !== Object.prototype && prototype !== null) {
44
+ throw inputError(`${label} has a custom prototype`);
45
+ }
46
+ if (keys.some((key) => typeof key !== 'string')) {
47
+ throw inputError(`${label} has symbol keys`);
48
+ }
49
+ for (const key of keys) {
50
+ let descriptor;
51
+ try {
52
+ descriptor = Object.getOwnPropertyDescriptor(value, key);
53
+ }
54
+ catch {
55
+ throw inputError(`${label} has an unreadable property`);
56
+ }
57
+ if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) {
58
+ throw inputError(`${label} has accessor or non-enumerable properties`);
59
+ }
60
+ }
61
+ return value;
62
+ }
63
+ function assertExactKeys(value, expected, label) {
64
+ const keys = Object.keys(value);
65
+ if (keys.length !== expected.length || keys.some((key) => !expected.includes(key))) {
66
+ throw inputError(`${label} has missing or undeclared fields`);
67
+ }
68
+ }
69
+ export function assertAnalystReviewInputGraph(value) {
70
+ const pending = [{ value, depth: 0, label: 'root' }];
71
+ const seen = new WeakSet();
72
+ let nodes = 0;
73
+ let textUnits = 0;
74
+ while (pending.length) {
75
+ const current = pending.pop();
76
+ const candidate = current.value;
77
+ if (typeof candidate === 'string') {
78
+ textUnits += candidate.length;
79
+ if (textUnits > MAX_ANALYST_REVIEW_STATE_BYTES * 2)
80
+ throw inputError('aggregate text exceeds its ceiling');
81
+ continue;
82
+ }
83
+ if (candidate === null || candidate === undefined || typeof candidate === 'boolean')
84
+ continue;
85
+ if (typeof candidate === 'number') {
86
+ if (!Number.isFinite(candidate))
87
+ throw inputError(`${current.label} contains a non-finite number`);
88
+ continue;
89
+ }
90
+ if (typeof candidate !== 'object')
91
+ throw inputError(`${current.label} contains a non-JSON value`);
92
+ if (current.depth > 8)
93
+ throw inputError('nesting exceeds its ceiling');
94
+ nodes += 1;
95
+ if (nodes > 8_000)
96
+ throw inputError('the graph exceeds its node ceiling');
97
+ if (seen.has(candidate))
98
+ throw inputError('cycles and shared object references are not supported');
99
+ seen.add(candidate);
100
+ if (Array.isArray(candidate)) {
101
+ if (Object.getPrototypeOf(candidate) !== Array.prototype || candidate.length > 2_000) {
102
+ throw inputError(`${current.label} exceeds its array bound or has a custom prototype`);
103
+ }
104
+ const keys = Reflect.ownKeys(candidate);
105
+ if (keys.some((key) => typeof key === 'symbol') || keys.length !== candidate.length + 1) {
106
+ throw inputError(`${current.label} is sparse or has custom fields`);
107
+ }
108
+ for (let index = 0; index < candidate.length; index += 1) {
109
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index));
110
+ if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) {
111
+ throw inputError(`${current.label} contains an accessor or sparse entry`);
112
+ }
113
+ pending.push({ value: descriptor.value, depth: current.depth + 1, label: `${current.label}[${index}]` });
114
+ }
115
+ continue;
116
+ }
117
+ const item = ordinaryRecord(candidate, current.label);
118
+ if (Object.keys(item).length > 16)
119
+ throw inputError(`${current.label} exceeds its key ceiling`);
120
+ for (const [key, child] of Object.entries(item)) {
121
+ pending.push({ value: child, depth: current.depth + 1, label: `${current.label}.${key}` });
122
+ }
123
+ }
124
+ }
125
+ function boundedText(value, maximum, label, required = true) {
126
+ if (typeof value !== 'string' || value.length > maximum * 4 || CONTROL_RE.test(value)) {
127
+ if (!required && (value === null || value === undefined || value === ''))
128
+ return '';
129
+ throw inputError(`${label} is invalid`);
130
+ }
131
+ const normalized = value.replace(/\s+/gu, ' ').trim();
132
+ if ((required && !normalized) || normalized.length > maximum)
133
+ throw inputError(`${label} is invalid`);
134
+ return normalized;
135
+ }
136
+ function timestamp(value, label, nullable = false) {
137
+ if (nullable && (value === null || value === undefined || value === ''))
138
+ return null;
139
+ if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
140
+ throw inputError(`${label} is invalid`);
141
+ const parsed = Date.parse(value);
142
+ if (!Number.isFinite(parsed))
143
+ throw inputError(`${label} must use an explicit valid date and time`);
144
+ const normalized = new Date(parsed).toISOString();
145
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/u.test(value))
146
+ throw inputError(`${label} must use an explicit timezone`);
147
+ return normalized;
148
+ }
149
+ function referenceList(value, label) {
150
+ if (!Array.isArray(value) || value.length > MAX_ANALYST_REVIEW_ASSOCIATIONS) {
151
+ throw inputError(`${label} exceeds its association ceiling`);
152
+ }
153
+ const normalized = value.map((entry) => {
154
+ if (typeof entry !== 'string' || !SAFE_REFERENCE_RE.test(entry))
155
+ throw inputError(`${label} contains an invalid identifier`);
156
+ return entry;
157
+ });
158
+ if (new Set(normalized).size !== normalized.length)
159
+ throw inputError(`${label} contains duplicate identifiers`);
160
+ return normalized.sort();
161
+ }
162
+ function fingerprint(value, label) {
163
+ if (typeof value !== 'string' || !SAFE_FINGERPRINT_RE.test(value))
164
+ throw inputError(`${label} is invalid`);
165
+ return value;
166
+ }
167
+ function disposition(value, label) {
168
+ if (typeof value !== 'string' || !DISPOSITION_VALUES.has(value))
169
+ throw inputError(`${label} is invalid`);
170
+ return value;
171
+ }
172
+ function family(value, label) {
173
+ if (typeof value !== 'string' || !FAMILY_VALUES.has(value))
174
+ throw inputError(`${label} is invalid`);
175
+ return value;
176
+ }
177
+ function normalizeSnapshot(raw, label) {
178
+ const value = ordinaryRecord(raw, label);
179
+ assertExactKeys(value, SNAPSHOT_KEYS, label);
180
+ const normalizedDisposition = disposition(value.disposition, `${label}.disposition`);
181
+ const normalized = {
182
+ reviewedFingerprint: fingerprint(value.reviewedFingerprint, `${label}.reviewedFingerprint`),
183
+ disposition: normalizedDisposition,
184
+ rationale: boundedText(value.rationale, MAX_ANALYST_REVIEW_RATIONALE_LENGTH, `${label}.rationale`),
185
+ reviewedAt: timestamp(value.reviewedAt, `${label}.reviewedAt`),
186
+ reviewDueAt: timestamp(value.reviewDueAt, `${label}.reviewDueAt`, true),
187
+ expiresAt: timestamp(value.expiresAt, `${label}.expiresAt`, true),
188
+ };
189
+ if ((normalizedDisposition === 'expected' || normalizedDisposition === 'suppressed') && !normalized.expiresAt) {
190
+ throw inputError(`${label}.expiresAt is required for a time-bounded disposition`);
191
+ }
192
+ if (normalized.expiresAt && Date.parse(normalized.expiresAt) <= Date.parse(normalized.reviewedAt)) {
193
+ throw inputError(`${label}.expiresAt must be later than reviewedAt`);
194
+ }
195
+ if (normalized.reviewDueAt && Date.parse(normalized.reviewDueAt) <= Date.parse(normalized.reviewedAt)) {
196
+ throw inputError(`${label}.reviewDueAt must be later than reviewedAt`);
197
+ }
198
+ return normalized;
199
+ }
200
+ function historyOmitted(value, label) {
201
+ if (!Number.isSafeInteger(value) || Number(value) < 0)
202
+ throw inputError(`${label} is invalid`);
203
+ return Number(value);
204
+ }
205
+ function normalizeStateRecord(raw, label) {
206
+ const value = ordinaryRecord(raw, label);
207
+ assertExactKeys(value, RECORD_KEYS, label);
208
+ if (typeof value.subjectKey !== 'string' || !SAFE_SUBJECT_RE.test(value.subjectKey)) {
209
+ throw inputError(`${label}.subjectKey is invalid`);
210
+ }
211
+ if (!Array.isArray(value.history) || value.history.length > MAX_ANALYST_REVIEW_HISTORY) {
212
+ throw inputError(`${label}.history exceeds its ceiling`);
213
+ }
214
+ const decision = normalizeSnapshot({
215
+ reviewedFingerprint: value.reviewedFingerprint,
216
+ disposition: value.disposition,
217
+ rationale: value.rationale,
218
+ reviewedAt: value.reviewedAt,
219
+ reviewDueAt: value.reviewDueAt,
220
+ expiresAt: value.expiresAt,
221
+ }, `${label}.decision`);
222
+ const history = value.history.map((entry, index) => normalizeSnapshot(entry, `${label}.history[${index}]`));
223
+ return {
224
+ subjectKey: value.subjectKey,
225
+ evidenceFamily: family(value.evidenceFamily, `${label}.evidenceFamily`),
226
+ ...decision,
227
+ caseIds: referenceList(value.caseIds, `${label}.caseIds`),
228
+ campaignIds: referenceList(value.campaignIds, `${label}.campaignIds`),
229
+ history,
230
+ historyOmitted: historyOmitted(value.historyOmitted, `${label}.historyOmitted`),
231
+ };
232
+ }
233
+ function byteLength(value) {
234
+ return new TextEncoder().encode(value).byteLength;
235
+ }
236
+ export function emptyAnalystReviewStateStore() {
237
+ return { schema: ANALYST_REVIEW_STATE_SCHEMA, version: ANALYST_REVIEW_STATE_SCHEMA_VERSION, records: [] };
238
+ }
239
+ export function analystReviewStateStoreVersion(raw) {
240
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
241
+ return null;
242
+ let descriptor;
243
+ try {
244
+ descriptor = Object.getOwnPropertyDescriptor(raw, 'version');
245
+ }
246
+ catch {
247
+ return null;
248
+ }
249
+ return descriptor && 'value' in descriptor && Number.isSafeInteger(descriptor.value) && Number(descriptor.value) > 0
250
+ ? Number(descriptor.value)
251
+ : null;
252
+ }
253
+ export function normalizeAnalystReviewStateStore(raw) {
254
+ if (raw === null || raw === undefined)
255
+ return emptyAnalystReviewStateStore();
256
+ assertAnalystReviewInputGraph(raw);
257
+ const value = ordinaryRecord(raw, 'root');
258
+ assertExactKeys(value, STORE_KEYS, 'root');
259
+ if (value.schema !== ANALYST_REVIEW_STATE_SCHEMA)
260
+ throw new Error('This data is not a WHOISleuth analyst review-state document.');
261
+ if (value.version !== ANALYST_REVIEW_STATE_SCHEMA_VERSION) {
262
+ if (typeof value.version === 'number' && Number.isSafeInteger(value.version) && value.version > ANALYST_REVIEW_STATE_SCHEMA_VERSION) {
263
+ throw new Error(`Analyst review state uses newer schema ${value.version}. Update the app before reading or changing it.`);
264
+ }
265
+ throw new Error(`Expected analyst review-state schema ${ANALYST_REVIEW_STATE_SCHEMA_VERSION}.`);
266
+ }
267
+ if (!Array.isArray(value.records) || value.records.length > MAX_ANALYST_REVIEW_STATE_RECORDS) {
268
+ throw inputError('records exceed the collection ceiling');
269
+ }
270
+ const records = value.records.map((entry, index) => normalizeStateRecord(entry, `records[${index}]`));
271
+ if (new Set(records.map((entry) => entry.subjectKey)).size !== records.length) {
272
+ throw inputError('records contain duplicate subject keys');
273
+ }
274
+ records.sort((left, right) => left.subjectKey.localeCompare(right.subjectKey, 'en'));
275
+ const store = {
276
+ schema: ANALYST_REVIEW_STATE_SCHEMA,
277
+ version: ANALYST_REVIEW_STATE_SCHEMA_VERSION,
278
+ records,
279
+ };
280
+ if (byteLength(JSON.stringify(store)) > MAX_ANALYST_REVIEW_STATE_BYTES) {
281
+ throw new Error('Analyst review state exceeds the 512 KiB browser-local storage limit.');
282
+ }
283
+ return store;
284
+ }
285
+ export function serializeAnalystReviewStateStore(raw) {
286
+ return JSON.stringify(normalizeAnalystReviewStateStore(raw));
287
+ }
288
+ export function serialiseAnalystReviewStateJson(value) {
289
+ return `${JSON.stringify(normalizeAnalystReviewStateStore(value), null, 2)}\n`;
290
+ }
291
+ function compareCodeUnits(left, right) {
292
+ return left < right ? -1 : left > right ? 1 : 0;
293
+ }
294
+ function canonicalIdentityValue(value) {
295
+ if (value === undefined)
296
+ return '["undefined"]';
297
+ if (value === null)
298
+ return '["null"]';
299
+ if (typeof value === 'string')
300
+ return `["string",${JSON.stringify(value)}]`;
301
+ if (typeof value === 'boolean')
302
+ return `["boolean",${value ? 'true' : 'false'}]`;
303
+ if (typeof value === 'number') {
304
+ if (!Number.isFinite(value))
305
+ throw inputError('identity input contains a non-finite number');
306
+ return `["number",${JSON.stringify(Object.is(value, -0) ? '-0' : String(value))}]`;
307
+ }
308
+ if (Array.isArray(value))
309
+ return `["array",[${value.map(canonicalIdentityValue).join(',')}]]`;
310
+ const candidate = value;
311
+ const entries = Object.keys(candidate)
312
+ .sort(compareCodeUnits)
313
+ .map((key) => `[${JSON.stringify(key)},${canonicalIdentityValue(candidate[key])}]`);
314
+ return `["object",[${entries.join(',')}]]`;
315
+ }
316
+ export function canonicalAnalystReviewIdentityJson(parts) {
317
+ if (!Array.isArray(parts))
318
+ throw inputError('identity parts must be an array');
319
+ assertAnalystReviewInputGraph(parts);
320
+ const source = canonicalIdentityValue(parts);
321
+ if (new TextEncoder().encode(source).byteLength > MAX_ANALYST_REVIEW_IDENTITY_BYTES) {
322
+ throw inputError(`identity input exceeds its ${MAX_ANALYST_REVIEW_IDENTITY_BYTES}-byte canonical ceiling`);
323
+ }
324
+ return source;
325
+ }
326
+ const SHA256_CONSTANTS = Object.freeze([
327
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
328
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
329
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
330
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
331
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
332
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
333
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
334
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
335
+ ]);
336
+ function rotateRight(value, bits) {
337
+ return (value >>> bits) | (value << (32 - bits));
338
+ }
339
+ function sha256Hex(bytes) {
340
+ // This digest is a collision-resistant record identity, not a password,
341
+ // signature, MAC, or substitute for the platform cryptography used there.
342
+ const paddedLength = Math.ceil((bytes.byteLength + 9) / 64) * 64;
343
+ const padded = new Uint8Array(paddedLength);
344
+ padded.set(bytes);
345
+ padded[bytes.byteLength] = 0x80;
346
+ const view = new DataView(padded.buffer);
347
+ const bitLength = bytes.byteLength * 8;
348
+ view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x1_0000_0000), false);
349
+ view.setUint32(paddedLength - 4, bitLength >>> 0, false);
350
+ const state = new Uint32Array([
351
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
352
+ 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
353
+ ]);
354
+ const words = new Uint32Array(64);
355
+ for (let offset = 0; offset < paddedLength; offset += 64) {
356
+ for (let index = 0; index < 16; index += 1)
357
+ words[index] = view.getUint32(offset + index * 4, false);
358
+ for (let index = 16; index < 64; index += 1) {
359
+ const left = words[index - 15];
360
+ const right = words[index - 2];
361
+ const sigma0 = rotateRight(left, 7) ^ rotateRight(left, 18) ^ (left >>> 3);
362
+ const sigma1 = rotateRight(right, 17) ^ rotateRight(right, 19) ^ (right >>> 10);
363
+ words[index] = (words[index - 16] + sigma0 + words[index - 7] + sigma1) >>> 0;
364
+ }
365
+ let [a, b, c, d, e, f, g, h] = state;
366
+ for (let index = 0; index < 64; index += 1) {
367
+ const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
368
+ const choice = (e & f) ^ (~e & g);
369
+ const first = (h + sum1 + choice + SHA256_CONSTANTS[index] + words[index]) >>> 0;
370
+ const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
371
+ const majority = (a & b) ^ (a & c) ^ (b & c);
372
+ const second = (sum0 + majority) >>> 0;
373
+ h = g;
374
+ g = f;
375
+ f = e;
376
+ e = (d + first) >>> 0;
377
+ d = c;
378
+ c = b;
379
+ b = a;
380
+ a = (first + second) >>> 0;
381
+ }
382
+ state[0] = (state[0] + a) >>> 0;
383
+ state[1] = (state[1] + b) >>> 0;
384
+ state[2] = (state[2] + c) >>> 0;
385
+ state[3] = (state[3] + d) >>> 0;
386
+ state[4] = (state[4] + e) >>> 0;
387
+ state[5] = (state[5] + f) >>> 0;
388
+ state[6] = (state[6] + g) >>> 0;
389
+ state[7] = (state[7] + h) >>> 0;
390
+ }
391
+ return [...state].map((value) => value.toString(16).padStart(8, '0')).join('');
392
+ }
393
+ function stableHash(parts) {
394
+ return sha256Hex(new TextEncoder().encode(canonicalAnalystReviewIdentityJson(parts)));
395
+ }
396
+ export function analystReviewSubjectKey(evidenceFamily, stableParts) {
397
+ if (!FAMILY_VALUES.has(evidenceFamily))
398
+ throw new TypeError('Choose a supported Review Item evidence family.');
399
+ return `review:${evidenceFamily}:${stableHash(stableParts)}`;
400
+ }
401
+ export function analystReviewMaterialFingerprint(materialParts) {
402
+ return `material:${stableHash(materialParts)}`;
403
+ }
404
+ function currentSnapshot(record) {
405
+ return {
406
+ reviewedFingerprint: record.reviewedFingerprint,
407
+ disposition: record.disposition,
408
+ rationale: record.rationale,
409
+ reviewedAt: record.reviewedAt,
410
+ reviewDueAt: record.reviewDueAt,
411
+ expiresAt: record.expiresAt,
412
+ };
413
+ }
414
+ function snapshotIdentity(snapshot) {
415
+ return JSON.stringify([
416
+ snapshot.reviewedFingerprint,
417
+ snapshot.disposition,
418
+ snapshot.rationale,
419
+ snapshot.reviewedAt,
420
+ snapshot.reviewDueAt,
421
+ snapshot.expiresAt,
422
+ ]);
423
+ }
424
+ function mergedReviewHistory(current, other) {
425
+ const currentIdentity = snapshotIdentity(currentSnapshot(current));
426
+ const byIdentity = new Map();
427
+ for (const snapshot of [...current.history, currentSnapshot(other), ...other.history]) {
428
+ const identity = snapshotIdentity(snapshot);
429
+ if (identity !== currentIdentity && !byIdentity.has(identity))
430
+ byIdentity.set(identity, snapshot);
431
+ }
432
+ const ordered = [...byIdentity.entries()]
433
+ .sort(([leftIdentity, left], [rightIdentity, right]) => (right.reviewedAt.localeCompare(left.reviewedAt)
434
+ || leftIdentity.localeCompare(rightIdentity)));
435
+ return {
436
+ history: ordered.slice(0, MAX_ANALYST_REVIEW_HISTORY).map(([, snapshot]) => snapshot),
437
+ historyOmitted: Math.max(current.historyOmitted, other.historyOmitted)
438
+ + Math.max(0, ordered.length - MAX_ANALYST_REVIEW_HISTORY),
439
+ };
440
+ }
441
+ export function setAnalystReviewDecision(raw, item, input) {
442
+ const store = normalizeAnalystReviewStateStore(raw);
443
+ if (!SAFE_SUBJECT_RE.test(item.subjectKey) || !SAFE_FINGERPRINT_RE.test(item.materialFingerprint)) {
444
+ throw new TypeError('The Review Item identity is invalid. Reload retained evidence before recording a decision.');
445
+ }
446
+ if (!DISPOSITION_VALUES.has(input.disposition))
447
+ throw new TypeError('Choose a valid Review Item disposition.');
448
+ if (input.disposition === 'resolved' && (item.completeness !== 'complete' || item.age === 'stale')) {
449
+ throw new Error('Partial, inconclusive, or stale evidence cannot resolve a Review Item. Refresh or attach current complete evidence first.');
450
+ }
451
+ const rationale = boundedText(input.rationale, MAX_ANALYST_REVIEW_RATIONALE_LENGTH, 'rationale');
452
+ const reviewedAt = timestamp(input.reviewedAt ?? new Date().toISOString(), 'reviewedAt');
453
+ const expiresAt = timestamp(input.expiresAt, 'expiresAt', true);
454
+ const reviewDueAt = timestamp(input.reviewDueAt, 'reviewDueAt', true);
455
+ if ((input.disposition === 'expected' || input.disposition === 'suppressed') && !expiresAt) {
456
+ throw new Error('Expected and suppressed Review Items require an expiry so the evidence returns to review.');
457
+ }
458
+ if (expiresAt && Date.parse(expiresAt) <= Date.parse(reviewedAt)) {
459
+ throw new Error('Review Item expiry must be later than the review time.');
460
+ }
461
+ if (reviewDueAt && Date.parse(reviewDueAt) <= Date.parse(reviewedAt)) {
462
+ throw new Error('The next review time must be later than the review time.');
463
+ }
464
+ const previous = store.records.find((record) => record.subjectKey === item.subjectKey) ?? null;
465
+ const caseIds = referenceList(input.caseIds ?? (item.caseId ? [item.caseId] : []), 'caseIds');
466
+ const campaignIds = referenceList(input.campaignIds ?? item.campaignIds, 'campaignIds');
467
+ const priorHistory = previous ? [currentSnapshot(previous), ...previous.history] : [];
468
+ const next = {
469
+ subjectKey: item.subjectKey,
470
+ reviewedFingerprint: item.materialFingerprint,
471
+ evidenceFamily: item.evidenceFamily,
472
+ disposition: input.disposition,
473
+ rationale,
474
+ reviewedAt,
475
+ reviewDueAt,
476
+ expiresAt,
477
+ caseIds,
478
+ campaignIds,
479
+ history: priorHistory.slice(0, MAX_ANALYST_REVIEW_HISTORY),
480
+ historyOmitted: (previous?.historyOmitted ?? 0)
481
+ + Math.max(0, priorHistory.length - MAX_ANALYST_REVIEW_HISTORY),
482
+ };
483
+ return normalizeAnalystReviewStateStore({
484
+ schema: ANALYST_REVIEW_STATE_SCHEMA,
485
+ version: ANALYST_REVIEW_STATE_SCHEMA_VERSION,
486
+ records: [next, ...store.records.filter((record) => record.subjectKey !== item.subjectKey)],
487
+ });
488
+ }
489
+ export function analystReviewLifecycle(item, raw, nowRaw = new Date().toISOString()) {
490
+ const store = normalizeAnalystReviewStateStore(raw);
491
+ const decision = store.records.find((record) => record.subjectKey === item.subjectKey) ?? null;
492
+ if (!decision) {
493
+ return { state: 'open', effectiveDisposition: 'open', decision: null, reason: 'No analyst lifecycle decision is retained.', expired: false, invalidated: false, recurred: false, reviewDue: false };
494
+ }
495
+ const now = timestamp(nowRaw, 'now');
496
+ const invalidated = decision.reviewedFingerprint !== item.materialFingerprint;
497
+ const expired = decision.expiresAt !== null && Date.parse(decision.expiresAt) <= Date.parse(now);
498
+ const reviewDue = decision.reviewDueAt !== null && Date.parse(decision.reviewDueAt) <= Date.parse(now);
499
+ const priorClosed = decision.disposition !== 'open' || decision.history.some((entry) => entry.disposition !== 'open');
500
+ if (invalidated) {
501
+ return {
502
+ state: 'invalidated', effectiveDisposition: 'open', decision,
503
+ reason: 'Material evidence changed after the retained decision. The previous rationale remains in history, but the item is open again.',
504
+ expired: false, invalidated: true, recurred: priorClosed, reviewDue,
505
+ };
506
+ }
507
+ if (expired) {
508
+ return {
509
+ state: 'expired', effectiveDisposition: 'open', decision,
510
+ reason: 'The time-bounded decision expired. The item has returned to review without changing its evidence.',
511
+ expired: true, invalidated: false, recurred: true, reviewDue: true,
512
+ };
513
+ }
514
+ if (reviewDue && decision.disposition !== 'open') {
515
+ return {
516
+ state: 'recurred', effectiveDisposition: 'open', decision,
517
+ reason: 'The retained follow-up time has arrived. The previous decision remains historical and the item is open for review.',
518
+ expired: false, invalidated: false, recurred: true, reviewDue: true,
519
+ };
520
+ }
521
+ if (decision.disposition === 'resolved' && (item.completeness !== 'complete' || item.age === 'stale')) {
522
+ return {
523
+ state: 'invalidated', effectiveDisposition: 'open', decision,
524
+ reason: 'The current evidence is partial, inconclusive, or stale, so the retained resolved decision cannot close this item.',
525
+ expired: false, invalidated: true, recurred: true, reviewDue,
526
+ };
527
+ }
528
+ return {
529
+ state: decision.disposition,
530
+ effectiveDisposition: decision.disposition,
531
+ decision,
532
+ reason: decision.disposition === 'open'
533
+ ? 'The analyst retained this item as open.'
534
+ : `The analyst retained this item as ${decision.disposition}; the source evidence remains unchanged.`,
535
+ expired: false,
536
+ invalidated: false,
537
+ recurred: false,
538
+ reviewDue,
539
+ };
540
+ }
541
+ export function orphanedAnalystReviewStates(raw, currentItems) {
542
+ const store = normalizeAnalystReviewStateStore(raw);
543
+ const current = new Set(currentItems.slice(0, MAX_ANALYST_REVIEW_ITEMS).map((item) => item.subjectKey));
544
+ return store.records.filter((record) => !current.has(record.subjectKey));
545
+ }
546
+ export function buildAnalystReviewStateExport(raw) {
547
+ return normalizeAnalystReviewStateStore(raw);
548
+ }
549
+ export function analystReviewStateRecords(raw) {
550
+ return normalizeAnalystReviewStateStore(raw).records;
551
+ }
552
+ export function analystReviewStateStoreFromRecords(records) {
553
+ return normalizeAnalystReviewStateStore({
554
+ schema: ANALYST_REVIEW_STATE_SCHEMA,
555
+ version: ANALYST_REVIEW_STATE_SCHEMA_VERSION,
556
+ records,
557
+ });
558
+ }
559
+ export function mergeAnalystReviewStateStores(localRaw, importedRaw) {
560
+ const local = normalizeAnalystReviewStateStore(localRaw);
561
+ const imported = normalizeAnalystReviewStateStore(importedRaw);
562
+ const records = new Map(local.records.map((record) => [record.subjectKey, record]));
563
+ let added = 0;
564
+ let updated = 0;
565
+ let skipped = 0;
566
+ for (const candidate of imported.records) {
567
+ const existing = records.get(candidate.subjectKey);
568
+ if (!existing) {
569
+ records.set(candidate.subjectKey, candidate);
570
+ added += 1;
571
+ }
572
+ else {
573
+ const current = candidate.reviewedAt > existing.reviewedAt ? candidate : existing;
574
+ const other = current === candidate ? existing : candidate;
575
+ const merged = { ...current, ...mergedReviewHistory(current, other) };
576
+ if (JSON.stringify(merged) === JSON.stringify(existing)) {
577
+ skipped += 1;
578
+ }
579
+ else {
580
+ records.set(candidate.subjectKey, merged);
581
+ updated += 1;
582
+ }
583
+ }
584
+ }
585
+ return {
586
+ store: normalizeAnalystReviewStateStore({
587
+ schema: ANALYST_REVIEW_STATE_SCHEMA,
588
+ version: ANALYST_REVIEW_STATE_SCHEMA_VERSION,
589
+ records: [...records.values()],
590
+ }),
591
+ added,
592
+ updated,
593
+ skipped,
594
+ };
595
+ }
596
+ function developmentFingerprint(value, label) {
597
+ if (typeof value !== 'string' || !DEVELOPMENT_FINGERPRINT_RE.test(value))
598
+ throw inputError(`${label} is invalid`);
599
+ return `material:${stableHash(['retired-development-material', value])}`;
600
+ }
601
+ function normalizeDevelopmentSnapshot(raw, label) {
602
+ const value = ordinaryRecord(raw, label);
603
+ assertExactKeys(value, SNAPSHOT_KEYS, label);
604
+ const reviewedAt = timestamp(value.reviewedAt, `${label}.reviewedAt`);
605
+ const reviewDueAt = timestamp(value.reviewDueAt, `${label}.reviewDueAt`, true);
606
+ const expiresAt = timestamp(value.expiresAt, `${label}.expiresAt`, true);
607
+ return {
608
+ reviewedFingerprint: developmentFingerprint(value.reviewedFingerprint, `${label}.reviewedFingerprint`),
609
+ disposition: disposition(value.disposition, `${label}.disposition`),
610
+ rationale: boundedText(value.rationale, MAX_ANALYST_REVIEW_RATIONALE_LENGTH, `${label}.rationale`),
611
+ reviewedAt,
612
+ reviewDueAt: reviewDueAt && Date.parse(reviewDueAt) > Date.parse(reviewedAt) ? reviewDueAt : null,
613
+ expiresAt: expiresAt && Date.parse(expiresAt) > Date.parse(reviewedAt) ? expiresAt : null,
614
+ };
615
+ }
616
+ /**
617
+ * Converts only the short-lived local development identity format. The old
618
+ * decision becomes history under an orphaned, open subject so it can never
619
+ * suppress or resolve evidence produced by the corrected identity contract.
620
+ */
621
+ export function migrateDevelopmentAnalystReviewStateStore(raw) {
622
+ try {
623
+ return normalizeAnalystReviewStateStore(raw);
624
+ }
625
+ catch (strictCause) {
626
+ assertAnalystReviewInputGraph(raw);
627
+ const value = ordinaryRecord(raw, 'root');
628
+ assertExactKeys(value, STORE_KEYS, 'root');
629
+ if (value.schema !== ANALYST_REVIEW_STATE_SCHEMA || value.version !== ANALYST_REVIEW_STATE_SCHEMA_VERSION
630
+ || !Array.isArray(value.records) || value.records.length > MAX_ANALYST_REVIEW_STATE_RECORDS)
631
+ throw strictCause;
632
+ const records = value.records.map((entry, index) => {
633
+ const label = `records[${index}]`;
634
+ const candidate = ordinaryRecord(entry, label);
635
+ assertExactKeys(candidate, DEVELOPMENT_RECORD_KEYS, label);
636
+ if (typeof candidate.subjectKey !== 'string')
637
+ throw strictCause;
638
+ const subjectMatch = candidate.subjectKey.match(DEVELOPMENT_SUBJECT_RE);
639
+ if (!subjectMatch || !FAMILY_VALUES.has(subjectMatch[1]))
640
+ throw strictCause;
641
+ const evidenceFamily = family(candidate.evidenceFamily, `${label}.evidenceFamily`);
642
+ if (evidenceFamily !== subjectMatch[1])
643
+ throw inputError(`${label}.subjectKey family is inconsistent`);
644
+ const previous = normalizeDevelopmentSnapshot({
645
+ reviewedFingerprint: candidate.reviewedFingerprint,
646
+ disposition: candidate.disposition,
647
+ rationale: candidate.rationale,
648
+ reviewedAt: candidate.reviewedAt,
649
+ reviewDueAt: candidate.reviewDueAt,
650
+ expiresAt: candidate.expiresAt,
651
+ }, `${label}.decision`);
652
+ if (!Array.isArray(candidate.history) || candidate.history.length > MAX_ANALYST_REVIEW_HISTORY) {
653
+ throw inputError(`${label}.history exceeds its ceiling`);
654
+ }
655
+ const retainedHistory = [
656
+ previous,
657
+ ...candidate.history.map((snapshot, historyIndex) => normalizeDevelopmentSnapshot(snapshot, `${label}.history[${historyIndex}]`)),
658
+ ];
659
+ return {
660
+ subjectKey: `review:${evidenceFamily}:${stableHash(['retired-development-subject', candidate.subjectKey])}`,
661
+ reviewedFingerprint: previous.reviewedFingerprint,
662
+ evidenceFamily,
663
+ disposition: 'open',
664
+ rationale: 'A retired local development identity was reopened. Review the current source evidence before recording a new decision.',
665
+ reviewedAt: previous.reviewedAt,
666
+ reviewDueAt: null,
667
+ expiresAt: null,
668
+ caseIds: referenceList(candidate.caseIds, `${label}.caseIds`),
669
+ campaignIds: referenceList(candidate.campaignIds, `${label}.campaignIds`),
670
+ history: retainedHistory.slice(0, MAX_ANALYST_REVIEW_HISTORY),
671
+ historyOmitted: Math.max(0, retainedHistory.length - MAX_ANALYST_REVIEW_HISTORY),
672
+ };
673
+ });
674
+ return normalizeAnalystReviewStateStore({
675
+ schema: ANALYST_REVIEW_STATE_SCHEMA,
676
+ version: ANALYST_REVIEW_STATE_SCHEMA_VERSION,
677
+ records,
678
+ });
679
+ }
680
+ }