@slicedearth/whoisleuth-cli 1.47.4 → 2.0.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.
Files changed (236) 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/project-metadata.mjs +1 -1
  100. package/lib/provider-policy-admission.mjs +3 -1
  101. package/lib/rdap-bootstrap.mjs +76 -16
  102. package/lib/rdap-client.mjs +20 -12
  103. package/lib/rdap-normalization.mjs +306 -75
  104. package/lib/rdap-transport.mjs +2 -2
  105. package/lib/rdap-validation.mjs +65 -14
  106. package/lib/rdap.mjs +2 -2
  107. package/lib/registrable-domain.mjs +36 -0
  108. package/lib/registry-insights.mjs +7 -0
  109. package/lib/response-policy.mjs +27 -4
  110. package/lib/risk-calibration-summary.mjs +35 -33
  111. package/lib/risk-scoring.mjs +4 -3
  112. package/lib/safe-fetch.mjs +62 -40
  113. package/lib/security-txt.mjs +1 -1
  114. package/lib/smtp-transport-review.mjs +8 -5
  115. package/lib/sslbl-intelligence.mjs +6 -8
  116. package/lib/static-html-analysis.mjs +301 -4
  117. package/lib/structured-data-identity.mjs +51 -19
  118. package/lib/threat-intelligence-fixtures.mjs +2 -4
  119. package/lib/threat-intelligence-manifests.mjs +2 -7
  120. package/lib/threat-intelligence-runtime.mjs +3 -5
  121. package/lib/threat-intelligence-types.mjs +3 -0
  122. package/lib/threatfox-intelligence.mjs +7 -3
  123. package/lib/tls-intelligence.mjs +4 -4
  124. package/lib/urlhaus-intelligence.mjs +4 -2
  125. package/lib/urlscan-intelligence.mjs +6 -5
  126. package/lib/website-security-posture.mjs +1 -1
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-authority.mjs +43 -8
  129. package/lib/whois-chain.mjs +25 -3
  130. package/lib/whois-normalization.mjs +3 -1
  131. package/lib/whois-transport.mjs +12 -3
  132. package/lib/zone-intent-review.mjs +1 -1
  133. package/package.json +13 -5
  134. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  135. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  136. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  137. package/packages/cases/case-migration-model.mjs +476 -0
  138. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  139. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  140. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  141. package/packages/cases/case-record-model.mjs +9 -0
  142. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  143. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  144. package/packages/cases/case-response-model.mjs +1604 -0
  145. package/packages/cases/case-response-packet.mjs +1312 -0
  146. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  147. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  148. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  149. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  150. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  151. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  152. package/packages/comparison/favicon-similarity.mjs +78 -0
  153. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  154. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  155. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  156. package/packages/contracts/analyst-review-state.mjs +50 -0
  157. package/packages/contracts/case-portability.mjs +1310 -0
  158. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  159. package/packages/contracts/cli-lookup.mjs +394 -0
  160. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  161. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  162. package/packages/contracts/domain-control-monitor.mjs +310 -0
  163. package/packages/contracts/domain-control-review.mjs +828 -0
  164. package/packages/contracts/domain-name.mjs +4 -0
  165. package/packages/contracts/external-observation-interchange.mjs +224 -0
  166. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  167. package/packages/contracts/investigation-portability.mjs +331 -0
  168. package/packages/contracts/offline-comparison.mjs +160 -0
  169. package/packages/contracts/risk-calibration.mjs +1288 -0
  170. package/packages/contracts/schema-compatibility.mjs +71 -0
  171. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  172. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  173. package/packages/contracts/workspace-portability.mjs +1347 -0
  174. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  175. package/packages/evidence/decision-fact.mjs +735 -0
  176. package/packages/evidence/domain-control-runtime.mjs +547 -0
  177. package/packages/evidence/domain-name.mjs +41 -0
  178. package/packages/evidence/observation.mjs +384 -0
  179. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  180. package/packages/interchange/retained-text.mjs +8 -0
  181. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  182. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  183. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  184. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  185. package/packages/investigation/bulk-review-policy.mjs +1 -0
  186. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  187. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  188. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  189. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  190. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  191. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  192. package/packages/monitoring/analyst-review-state.mjs +680 -0
  193. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +71 -48
  194. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  195. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  196. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  197. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  198. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  199. package/packages/workspace/hostile-input.mjs +172 -0
  200. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  201. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  202. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  203. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  204. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  205. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  206. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  207. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  208. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  209. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  210. package/third-party-notices.txt +14 -14
  211. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  212. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  213. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  214. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  215. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  216. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  217. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  218. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  219. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  220. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  221. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  222. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  223. package/frontend/src/lib/analysis/scoring.js +0 -55
  224. package/frontend/src/lib/analysis/utils.js +0 -255
  225. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  226. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  227. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  228. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  229. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  230. package/lib/bounded-json-response.mjs +0 -170
  231. package/lib/lookup-response-contract.mjs +0 -1034
  232. package/lib/observation.mjs +0 -115
  233. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  236. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -0,0 +1,1604 @@
1
+ // Bounded, framework-neutral analyst response records. These records are
2
+ // deliberately separate from collected evidence snapshots: a pin describes a
3
+ // fact selected by an analyst, a decision records analyst reasoning, and an
4
+ // action records a reviewed external or internal follow-up.
5
+ import { MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_CLOSURES, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, } from "../contracts/case-portability.mjs";
6
+ import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
7
+ export { MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_CLOSURES, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, };
8
+ export const CASE_EVIDENCE_RELATION_STANCES = ['supports', 'contradicts', 'unresolved'];
9
+ export const CASE_PIN_COMPLETENESS = ['complete', 'partial', 'inconclusive', 'unknown'];
10
+ export const CASE_TRANSITION_EXPECTATIONS = ['preserve', 'change', 'review'];
11
+ export const CASE_ACTION_TYPES = [
12
+ 'registrar_report',
13
+ 'registry_report',
14
+ 'network_hosting_report',
15
+ 'security_contact_report',
16
+ 'defensive_control',
17
+ 'internal_review',
18
+ ];
19
+ export const CASE_ACTION_STATES = [
20
+ 'drafting',
21
+ 'ready_for_review',
22
+ 'reviewed',
23
+ 'authorised',
24
+ 'submitted',
25
+ 'acknowledged',
26
+ 'terminal',
27
+ ];
28
+ export const CASE_PROVIDER_OUTCOMES = [
29
+ 'accepted_for_review',
30
+ 'more_information_requested',
31
+ 'referred_elsewhere',
32
+ 'rejected_outside_policy_scope',
33
+ 'no_response',
34
+ 'partially_remediated',
35
+ 'provider_reports_resolved',
36
+ 'withdrawn',
37
+ 'duplicate',
38
+ ];
39
+ export const CASE_ACTION_EVENT_SOURCE_CLASSES = [
40
+ 'analyst',
41
+ 'provider',
42
+ 'browser_local',
43
+ 'import',
44
+ 'migration',
45
+ ];
46
+ export const CASE_OBSERVED_EFFECT_STATES = [
47
+ 'not_checked',
48
+ 'still_observed',
49
+ 'changed',
50
+ 'not_reproduced',
51
+ 'unavailable',
52
+ ];
53
+ export const CASE_OBSERVED_EFFECT_SOURCE_CLASSES = ['deployment', 'analyst', 'import'];
54
+ export const CASE_CLOSURE_REASONS = [
55
+ 'independently_not_reproduced',
56
+ 'infrastructure_changed',
57
+ 'provider_reported_resolution_not_independently_checked',
58
+ 'risk_accepted',
59
+ 'false_positive',
60
+ 'duplicate',
61
+ 'unable_to_proceed',
62
+ 'monitoring_transferred',
63
+ ];
64
+ export const CASE_ASSERTION_KINDS = [
65
+ 'verified_fact',
66
+ 'hypothesis',
67
+ 'unknown',
68
+ 'contradiction',
69
+ 'next_step',
70
+ ];
71
+ export const CASE_ASSERTION_STATES = ['open', 'resolved'];
72
+ export const CASE_ASSERTION_EXTERNAL_FORMATS = ['stix', 'misp'];
73
+ export const CASE_ASSERTION_EXTERNAL_ENTITY_TYPES = [
74
+ 'asn',
75
+ 'certificate',
76
+ 'domain',
77
+ 'hostname',
78
+ 'ipv4',
79
+ 'ipv6',
80
+ 'url',
81
+ ];
82
+ export const CASE_MANUAL_TRAIL_KINDS = ['pivot', 'review', 'handoff'];
83
+ export const CASE_SIGHTING_STATES = [
84
+ 'observed_by_deployment',
85
+ 'reported_by_provider',
86
+ 'analyst_confirmed',
87
+ 'not_reproduced',
88
+ 'expired',
89
+ ];
90
+ export const CASE_SIGHTING_CATEGORIES = [
91
+ 'registration',
92
+ 'delegation',
93
+ 'certificate',
94
+ 'mail',
95
+ 'website',
96
+ 'infrastructure',
97
+ 'other',
98
+ ];
99
+ const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/u;
100
+ const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
101
+ const COMPLETENESS = new Set(CASE_PIN_COMPLETENESS);
102
+ const TRANSITION_EXPECTATIONS = new Set(CASE_TRANSITION_EXPECTATIONS);
103
+ const ACTION_TYPES = new Set(CASE_ACTION_TYPES);
104
+ const ACTION_STATES = new Set(CASE_ACTION_STATES);
105
+ const PROVIDER_OUTCOMES = new Set(CASE_PROVIDER_OUTCOMES);
106
+ const ACTION_EVENT_SOURCE_CLASSES = new Set(CASE_ACTION_EVENT_SOURCE_CLASSES);
107
+ const OBSERVED_EFFECT_STATES = new Set(CASE_OBSERVED_EFFECT_STATES);
108
+ const OBSERVED_EFFECT_SOURCE_CLASSES = new Set(CASE_OBSERVED_EFFECT_SOURCE_CLASSES);
109
+ const CLOSURE_REASONS = new Set(CASE_CLOSURE_REASONS);
110
+ const ASSERTION_KINDS = new Set(CASE_ASSERTION_KINDS);
111
+ const ASSERTION_STATES = new Set(CASE_ASSERTION_STATES);
112
+ const EVIDENCE_RELATION_STANCES = new Set(CASE_EVIDENCE_RELATION_STANCES);
113
+ const ASSERTION_EXTERNAL_FORMATS = new Set(CASE_ASSERTION_EXTERNAL_FORMATS);
114
+ const ASSERTION_EXTERNAL_ENTITY_TYPES = new Set(CASE_ASSERTION_EXTERNAL_ENTITY_TYPES);
115
+ const TRAIL_KINDS = new Set(CASE_MANUAL_TRAIL_KINDS);
116
+ const SIGHTING_STATES = new Set(CASE_SIGHTING_STATES);
117
+ const SIGHTING_CATEGORIES = new Set(CASE_SIGHTING_CATEGORIES);
118
+ const SHA256_RE = /^[a-f0-9]{64}$/u;
119
+ function currentActionNormalizationOptions(validEvidencePinIds) {
120
+ return validEvidencePinIds
121
+ ? { sourceVersion: 13, validEvidencePinIds }
122
+ : { sourceVersion: 13 };
123
+ }
124
+ function record(value) {
125
+ return value && typeof value === 'object' && !Array.isArray(value)
126
+ ? value
127
+ : {};
128
+ }
129
+ function text(value, maximum) {
130
+ if (typeof value !== 'string')
131
+ return '';
132
+ return value.replace(CONTROL_REPLACE_RE, ' ').trim().slice(0, maximum);
133
+ }
134
+ function timestamp(value, options = {}) {
135
+ return normalizeExplicitIsoTimestamp(value)
136
+ ?? (options.legacyTimestamps ? normalizeLegacyIsoTimestamp(value) : null);
137
+ }
138
+ function iso(value, fallback, options = {}) {
139
+ return timestamp(value, options) ?? fallback;
140
+ }
141
+ function optionalIso(value, options = {}) {
142
+ return timestamp(value, options);
143
+ }
144
+ function hash(value) {
145
+ let result = 2166136261 >>> 0;
146
+ for (let index = 0; index < value.length; index += 1) {
147
+ result ^= value.charCodeAt(index);
148
+ result = Math.imul(result, 16777619);
149
+ }
150
+ return (result >>> 0).toString(36);
151
+ }
152
+ function deterministicId(prefix, value) {
153
+ return `${prefix}-${hash(JSON.stringify(value))}`;
154
+ }
155
+ function freshId(prefix) {
156
+ if (typeof globalThis.crypto?.randomUUID === 'function')
157
+ return globalThis.crypto.randomUUID();
158
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
159
+ }
160
+ function safeId(value, prefix, raw) {
161
+ return typeof value === 'string' && SAFE_ID_RE.test(value)
162
+ ? value
163
+ : deterministicId(prefix, raw);
164
+ }
165
+ function limitations(value) {
166
+ if (!Array.isArray(value))
167
+ return [];
168
+ const unique = new Set();
169
+ for (const item of value.slice(0, MAX_RESPONSE_LIMITATIONS * 2)) {
170
+ const normalized = text(item, MAX_RESPONSE_LIMITATION_LENGTH);
171
+ if (normalized)
172
+ unique.add(normalized);
173
+ if (unique.size >= MAX_RESPONSE_LIMITATIONS)
174
+ break;
175
+ }
176
+ return [...unique];
177
+ }
178
+ function compareCodeUnits(left, right) {
179
+ return left < right ? -1 : left > right ? 1 : 0;
180
+ }
181
+ function lifecycleLimitations(value) {
182
+ return limitations(value).sort(compareCodeUnits);
183
+ }
184
+ function sourceSchema(value) {
185
+ const item = record(value);
186
+ const collection = text(item.collection, 80);
187
+ const schema = text(item.schema, 120);
188
+ const version = typeof item.version === 'number'
189
+ && Number.isSafeInteger(item.version)
190
+ && item.version > 0
191
+ && item.version <= 10_000
192
+ ? item.version
193
+ : null;
194
+ return collection && schema && version !== null ? { collection, schema, version } : null;
195
+ }
196
+ function certificateObservation(value, pin, options = {}) {
197
+ const item = record(value);
198
+ if (!Object.keys(item).length)
199
+ return null;
200
+ const keys = new Set([
201
+ 'eventId',
202
+ 'logId',
203
+ 'certificateSha256',
204
+ 'issuer',
205
+ 'notAfter',
206
+ 'dnsNameCount',
207
+ 'namesComplete',
208
+ ]);
209
+ if (Object.keys(item).some((key) => !keys.has(key)))
210
+ return null;
211
+ if (pin.sourceSchema?.collection !== 'external_observations'
212
+ || pin.sourceSchema.schema !== 'whoisleuth.certificate-observation-rows'
213
+ || (pin.field !== 'certificateSha256' && pin.field !== 'fingerprintSha256'))
214
+ return null;
215
+ const eventId = text(item.eventId, 64);
216
+ const logId = text(item.logId, 200);
217
+ const certificateSha256 = text(item.certificateSha256, 64).toLowerCase();
218
+ const issuer = item.issuer === null ? null : text(item.issuer, 160) || null;
219
+ const notAfter = item.notAfter === null ? null : optionalIso(item.notAfter, options);
220
+ const dnsNameCount = typeof item.dnsNameCount === 'number'
221
+ && Number.isSafeInteger(item.dnsNameCount)
222
+ && item.dnsNameCount >= 1
223
+ && item.dnsNameCount <= 100
224
+ ? item.dnsNameCount
225
+ : null;
226
+ if (!SAFE_ID_RE.test(eventId)
227
+ || !logId
228
+ || !SHA256_RE.test(certificateSha256)
229
+ || certificateSha256 !== pin.value.toLowerCase()
230
+ || dnsNameCount === null
231
+ || typeof item.namesComplete !== 'boolean'
232
+ || (item.notAfter !== null && notAfter === null))
233
+ return null;
234
+ return {
235
+ eventId,
236
+ logId,
237
+ certificateSha256,
238
+ issuer,
239
+ notAfter,
240
+ dnsNameCount,
241
+ namesComplete: item.namesComplete,
242
+ };
243
+ }
244
+ function uniqueIds(value, validIds) {
245
+ if (!Array.isArray(value))
246
+ return [];
247
+ const unique = new Set();
248
+ for (const item of value.slice(0, MAX_DECISION_PIN_REFERENCES * 2)) {
249
+ if (typeof item !== 'string' || !SAFE_ID_RE.test(item) || (validIds && !validIds.has(item)))
250
+ continue;
251
+ unique.add(item);
252
+ if (unique.size >= MAX_DECISION_PIN_REFERENCES)
253
+ break;
254
+ }
255
+ return [...unique];
256
+ }
257
+ function normalizeEvidenceRelations(value, legacyIds, validIds) {
258
+ const output = new Map();
259
+ if (Array.isArray(value)) {
260
+ for (const raw of value.slice(0, MAX_DECISION_PIN_REFERENCES * 2)) {
261
+ const item = record(raw);
262
+ const evidencePinId = typeof item.evidencePinId === 'string' && SAFE_ID_RE.test(item.evidencePinId)
263
+ ? item.evidencePinId
264
+ : '';
265
+ if (!evidencePinId || (validIds && !validIds.has(evidencePinId)))
266
+ continue;
267
+ if (typeof item.stance !== 'string' || !EVIDENCE_RELATION_STANCES.has(item.stance))
268
+ continue;
269
+ if (!output.has(evidencePinId))
270
+ output.set(evidencePinId, item.stance);
271
+ if (output.size >= MAX_DECISION_PIN_REFERENCES)
272
+ break;
273
+ }
274
+ }
275
+ if (!output.size) {
276
+ for (const evidencePinId of legacyIds)
277
+ output.set(evidencePinId, 'supports');
278
+ }
279
+ return [...output].map(([evidencePinId, stance]) => ({ evidencePinId, stance }));
280
+ }
281
+ function normalizePin(raw, fallback, options = {}) {
282
+ const item = record(raw);
283
+ const label = text(item.label, MAX_RESPONSE_LABEL_LENGTH);
284
+ const value = text(item.value, MAX_RESPONSE_VALUE_LENGTH);
285
+ if (!label || !value)
286
+ return null;
287
+ const createdAt = iso(item.createdAt, fallback, options);
288
+ const normalizedSourceSchema = sourceSchema(item.sourceSchema);
289
+ const normalizedField = text(item.field, 120) || null;
290
+ const normalized = {
291
+ id: safeId(item.id, 'pin', { label, value, createdAt }),
292
+ checkpointId: typeof item.checkpointId === 'string' && SAFE_ID_RE.test(item.checkpointId)
293
+ ? item.checkpointId
294
+ : null,
295
+ field: normalizedField,
296
+ category: text(item.category, 80) || null,
297
+ label,
298
+ value,
299
+ source: text(item.source, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_selected',
300
+ sourceState: text(item.sourceState, 40) || null,
301
+ sourceSchema: normalizedSourceSchema,
302
+ observedAt: iso(item.observedAt, createdAt, options),
303
+ collectionDepth: item.collectionDepth === 'deep' || item.collectionDepth === 'fast'
304
+ ? item.collectionDepth
305
+ : 'unknown',
306
+ completeness: typeof item.completeness === 'string' && COMPLETENESS.has(item.completeness)
307
+ ? item.completeness
308
+ : 'unknown',
309
+ truncated: typeof item.truncated === 'boolean' ? item.truncated : null,
310
+ transitionExpectation: typeof item.transitionExpectation === 'string'
311
+ && TRANSITION_EXPECTATIONS.has(item.transitionExpectation)
312
+ ? item.transitionExpectation
313
+ : null,
314
+ limitations: limitations(item.limitations),
315
+ createdAt,
316
+ };
317
+ normalized.certificateObservation = options.allowCertificateObservation === false
318
+ ? null
319
+ : certificateObservation(item.certificateObservation, {
320
+ field: normalizedField,
321
+ value,
322
+ sourceSchema: normalizedSourceSchema,
323
+ }, options);
324
+ return normalized;
325
+ }
326
+ export function normalizeCaseEvidencePins(raw, fallback, options = {}) {
327
+ if (!Array.isArray(raw))
328
+ return [];
329
+ const byId = new Map();
330
+ for (const item of raw.slice(0, MAX_CASE_EVIDENCE_PINS * 2)) {
331
+ const normalized = normalizePin(item, fallback, options);
332
+ if (normalized && !byId.has(normalized.id))
333
+ byId.set(normalized.id, normalized);
334
+ }
335
+ return [...byId.values()]
336
+ .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))
337
+ .slice(-MAX_CASE_EVIDENCE_PINS);
338
+ }
339
+ export function appendCaseEvidencePin(current, raw, now) {
340
+ const item = record(raw);
341
+ const created = normalizePin({ ...item, id: freshId('pin'), createdAt: now }, now);
342
+ if (!created)
343
+ throw new Error('An evidence pin requires a label and value.');
344
+ return normalizeCaseEvidencePins([...current, created], now);
345
+ }
346
+ export function appendCaseEvidencePins(current, raw, now) {
347
+ if (!Array.isArray(raw) || !raw.length)
348
+ throw new Error('An evidence checkpoint requires at least one selected fact.');
349
+ let output = [...current];
350
+ let added = 0;
351
+ for (const item of raw.slice(0, MAX_CASE_CHECKPOINT_FACTS)) {
352
+ output = appendCaseEvidencePin(output, item, now);
353
+ added += 1;
354
+ }
355
+ if (!added)
356
+ throw new Error('An evidence checkpoint requires at least one valid selected fact.');
357
+ return output;
358
+ }
359
+ function normalizeDecision(raw, fallback, validPinIds, options = {}) {
360
+ const item = record(raw);
361
+ const summary = text(item.summary, MAX_RESPONSE_LABEL_LENGTH);
362
+ const rationale = text(item.rationale, MAX_RESPONSE_RATIONALE_LENGTH);
363
+ if (!summary || !rationale)
364
+ return null;
365
+ const createdAt = iso(item.createdAt, fallback, options);
366
+ return {
367
+ id: safeId(item.id, 'decision', { summary, rationale, createdAt }),
368
+ summary,
369
+ rationale,
370
+ evidencePinIds: uniqueIds(item.evidencePinIds, validPinIds),
371
+ createdAt,
372
+ };
373
+ }
374
+ export function normalizeCaseDecisions(raw, fallback, validPinIds, options = {}) {
375
+ if (!Array.isArray(raw))
376
+ return [];
377
+ const byId = new Map();
378
+ for (const item of raw.slice(0, MAX_CASE_DECISIONS * 2)) {
379
+ const normalized = normalizeDecision(item, fallback, validPinIds, options);
380
+ if (normalized && !byId.has(normalized.id))
381
+ byId.set(normalized.id, normalized);
382
+ }
383
+ return [...byId.values()]
384
+ .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))
385
+ .slice(-MAX_CASE_DECISIONS);
386
+ }
387
+ export function appendCaseDecision(current, raw, now, validPinIds) {
388
+ const item = record(raw);
389
+ const created = normalizeDecision({ ...item, id: freshId('decision'), createdAt: now }, now, validPinIds);
390
+ if (!created)
391
+ throw new Error('A decision requires a summary and rationale.');
392
+ return normalizeCaseDecisions([...current, created], now, validPinIds);
393
+ }
394
+ const LEGACY_ACTION_STATE_MAP = Object.freeze({
395
+ planned: 'drafting',
396
+ ready_for_review: 'ready_for_review',
397
+ submitted: 'submitted',
398
+ acknowledged: 'acknowledged',
399
+ resolved: 'terminal',
400
+ closed: 'terminal',
401
+ });
402
+ const ACTION_TRANSITIONS = Object.freeze({
403
+ drafting: new Set(['ready_for_review', 'terminal']),
404
+ ready_for_review: new Set(['drafting', 'reviewed', 'terminal']),
405
+ reviewed: new Set(['drafting', 'authorised', 'terminal']),
406
+ authorised: new Set(['drafting', 'submitted', 'terminal']),
407
+ submitted: new Set(['submitted', 'acknowledged', 'terminal']),
408
+ acknowledged: new Set(['acknowledged', 'terminal']),
409
+ terminal: new Set(),
410
+ });
411
+ function boundedCounter(value) {
412
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
413
+ ? Math.min(value, 1_000_000)
414
+ : 0;
415
+ }
416
+ function bytes(value) {
417
+ try {
418
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
419
+ }
420
+ catch {
421
+ return Number.POSITIVE_INFINITY;
422
+ }
423
+ }
424
+ export function isLegalCaseActionTransition(previousState, nextState, sourceClass = 'analyst') {
425
+ if (previousState === null) {
426
+ return sourceClass === 'migration'
427
+ || nextState === 'drafting' && (sourceClass === 'analyst' || sourceClass === 'browser_local');
428
+ }
429
+ if (!ACTION_TRANSITIONS[previousState].has(nextState))
430
+ return false;
431
+ if (sourceClass === 'provider' || sourceClass === 'import') {
432
+ return (previousState === 'submitted' || previousState === 'acknowledged')
433
+ && (nextState === 'submitted' || nextState === 'acknowledged' || nextState === 'terminal');
434
+ }
435
+ if (sourceClass === 'browser_local') {
436
+ return nextState === 'drafting' && ['ready_for_review', 'reviewed', 'authorised'].includes(previousState);
437
+ }
438
+ return sourceClass === 'analyst';
439
+ }
440
+ function normalizeActionEvent(raw, actionId, options = {}) {
441
+ const item = record(raw);
442
+ const previousState = item.previousState === null
443
+ ? null
444
+ : typeof item.previousState === 'string' && ACTION_STATES.has(item.previousState)
445
+ ? item.previousState
446
+ : undefined;
447
+ const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
448
+ ? item.nextState
449
+ : null;
450
+ const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
451
+ ? item.sourceClass
452
+ : null;
453
+ const occurredAt = optionalIso(item.occurredAt, options);
454
+ if (previousState === undefined || !nextState || !sourceClass || !occurredAt
455
+ || !isLegalCaseActionTransition(previousState, nextState, sourceClass)) {
456
+ return null;
457
+ }
458
+ const migrationSnapshot = sourceClass === 'migration' && previousState === null;
459
+ if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)
460
+ && sourceClass !== 'analyst' && !migrationSnapshot)
461
+ return null;
462
+ const provenance = text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || `${sourceClass}_record`;
463
+ const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
464
+ ? item.providerOutcome
465
+ : null;
466
+ if (providerOutcome && !['submitted', 'acknowledged', 'terminal'].includes(nextState) && sourceClass !== 'migration') {
467
+ return null;
468
+ }
469
+ if (providerOutcome && previousState === 'authorised' && nextState === 'submitted' && !migrationSnapshot) {
470
+ return null;
471
+ }
472
+ if (providerOutcome === 'no_response' && sourceClass === 'provider')
473
+ return null;
474
+ if (nextState === 'terminal' && previousState !== null
475
+ && ['drafting', 'ready_for_review', 'reviewed', 'authorised'].includes(previousState)
476
+ && (sourceClass !== 'analyst' || providerOutcome !== 'withdrawn')) {
477
+ return null;
478
+ }
479
+ if (providerOutcome === 'withdrawn'
480
+ && (nextState !== 'terminal' || (sourceClass !== 'analyst' && !migrationSnapshot)))
481
+ return null;
482
+ const candidateEvidencePinId = typeof item.evidencePinId === 'string' && SAFE_ID_RE.test(item.evidencePinId)
483
+ ? item.evidencePinId
484
+ : null;
485
+ const evidencePinId = candidateEvidencePinId
486
+ && (!options.validEvidencePinIds || options.validEvidencePinIds.has(candidateEvidencePinId))
487
+ ? candidateEvidencePinId
488
+ : null;
489
+ const eventMaterial = {
490
+ previousState,
491
+ nextState,
492
+ occurredAt,
493
+ sourceClass,
494
+ provenance,
495
+ reference: text(item.reference, MAX_RESPONSE_REFERENCE_LENGTH) || null,
496
+ evidencePinId,
497
+ limitations: limitations([
498
+ ...(item.evidencePinId != null && !evidencePinId ? ['A malformed or dangling evidence-pin reference was omitted from this transition.'] : []),
499
+ ...limitations(item.limitations),
500
+ ]),
501
+ providerOutcome,
502
+ outcomeDetail: text(item.outcomeDetail, MAX_RESPONSE_RATIONALE_LENGTH) || null,
503
+ originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
504
+ ? item.originActionId
505
+ : null,
506
+ };
507
+ return {
508
+ id: safeId(item.id, 'action-event', { actionId, ...eventMaterial }),
509
+ ...eventMaterial,
510
+ applied: false,
511
+ };
512
+ }
513
+ function legacyActionState(value) {
514
+ if (typeof value === 'string' && ACTION_STATES.has(value))
515
+ return value;
516
+ if (typeof value === 'string' && LEGACY_ACTION_STATE_MAP[value])
517
+ return LEGACY_ACTION_STATE_MAP[value];
518
+ return 'drafting';
519
+ }
520
+ function legacyActionEvent(item, actionId, createdAt, fallback, options) {
521
+ const nextState = legacyActionState(item.state);
522
+ const occurredAt = iso(item.updatedAt, createdAt || fallback, options);
523
+ const sourceVersion = options.sourceVersion;
524
+ const provenance = sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12
525
+ ? 'case_v12_legacy_snapshot'
526
+ : 'legacy_action_snapshot';
527
+ const legacyState = typeof item.state === 'string' ? item.state : 'planned';
528
+ const migrationLimitation = sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12
529
+ ? `Migrated from the Case v12 current state "${text(legacyState, 40)}"; pre-v13 transition history is unavailable.`
530
+ : 'Recovered a legacy current-state action; earlier transition history is unavailable.';
531
+ const eventMaterial = {
532
+ previousState: null,
533
+ nextState,
534
+ occurredAt,
535
+ sourceClass: 'migration',
536
+ provenance,
537
+ reference: text(item.reference, MAX_RESPONSE_REFERENCE_LENGTH) || null,
538
+ evidencePinId: null,
539
+ limitations: limitations([migrationLimitation, ...limitations(item.contactLimitations)]),
540
+ providerOutcome: !(sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12)
541
+ && typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
542
+ ? item.providerOutcome
543
+ : null,
544
+ outcomeDetail: text(item.outcome, MAX_RESPONSE_RATIONALE_LENGTH) || null,
545
+ originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
546
+ ? item.originActionId
547
+ : null,
548
+ };
549
+ return {
550
+ id: deterministicId('action-event', { actionId, ...eventMaterial }),
551
+ ...eventMaterial,
552
+ applied: true,
553
+ };
554
+ }
555
+ function currentActionRecoveryEvent(actionId, createdAt) {
556
+ const eventMaterial = {
557
+ previousState: null,
558
+ nextState: 'drafting',
559
+ occurredAt: createdAt,
560
+ sourceClass: 'browser_local',
561
+ provenance: 'case_v13_history_recovery',
562
+ reference: null,
563
+ evidencePinId: null,
564
+ limitations: ['The v13 action had no valid transition history. Mutable state, reference, and outcome projections were ignored.'],
565
+ providerOutcome: null,
566
+ outcomeDetail: null,
567
+ originActionId: null,
568
+ };
569
+ return {
570
+ id: deterministicId('action-event', { actionId, ...eventMaterial }),
571
+ ...eventMaterial,
572
+ applied: true,
573
+ };
574
+ }
575
+ function actionEventContent(event) {
576
+ const { applied: _applied, ...material } = event;
577
+ return JSON.stringify(material);
578
+ }
579
+ function projectActionHistory(source, omitted) {
580
+ let state = null;
581
+ let conflicts = 0;
582
+ const history = source.map((event, index) => {
583
+ if (state === null && omitted > 0 && index === 0 && event.previousState !== null)
584
+ state = event.previousState;
585
+ const applied = event.previousState === state
586
+ && isLegalCaseActionTransition(event.previousState, event.nextState, event.sourceClass);
587
+ if (applied)
588
+ state = event.nextState;
589
+ else
590
+ conflicts += 1;
591
+ return { ...event, applied };
592
+ });
593
+ return { history, state: state ?? 'drafting', conflicts };
594
+ }
595
+ function normalizeActionHistory(raw, item, actionId, createdAt, fallback, options) {
596
+ const source = Array.isArray(raw) ? raw : [];
597
+ let omitted = boundedCounter(item.historyOmitted);
598
+ let invalid = 0;
599
+ let duplicateConflict = 0;
600
+ const byId = new Map();
601
+ for (const candidate of source.slice(0, MAX_CASE_ACTION_EVENTS_PER_ACTION * 4)) {
602
+ const event = normalizeActionEvent(candidate, actionId, options);
603
+ if (!event) {
604
+ invalid += 1;
605
+ continue;
606
+ }
607
+ const existing = byId.get(event.id);
608
+ if (!existing)
609
+ byId.set(event.id, event);
610
+ else if (actionEventContent(existing) !== actionEventContent(event)) {
611
+ duplicateConflict += 1;
612
+ if (actionEventContent(event) < actionEventContent(existing))
613
+ byId.set(event.id, event);
614
+ }
615
+ }
616
+ invalid += Math.max(0, source.length - MAX_CASE_ACTION_EVENTS_PER_ACTION * 4);
617
+ if (!byId.size) {
618
+ const recovered = options.sourceVersion != null && options.sourceVersion <= 12
619
+ ? legacyActionEvent(item, actionId, createdAt, fallback, options)
620
+ : currentActionRecoveryEvent(actionId, createdAt);
621
+ byId.set(recovered.id, recovered);
622
+ }
623
+ let history = [...byId.values()].sort((left, right) => Date.parse(left.occurredAt) - Date.parse(right.occurredAt) || compareCodeUnits(left.id, right.id));
624
+ if (history.length > MAX_CASE_ACTION_EVENTS_PER_ACTION) {
625
+ omitted += history.length - MAX_CASE_ACTION_EVENTS_PER_ACTION;
626
+ history = history.slice(-MAX_CASE_ACTION_EVENTS_PER_ACTION);
627
+ }
628
+ while (history.length > 1 && bytes(history) > MAX_CASE_ACTION_BYTES) {
629
+ history.shift();
630
+ omitted += 1;
631
+ }
632
+ const totalOmitted = omitted + invalid + duplicateConflict;
633
+ const projected = projectActionHistory(history, totalOmitted);
634
+ const retainedLimitations = limitations(item.historyLimitations).filter((item) => !/^\d+ earlier action transition events? omitted by bounded retention\.$/u.test(item));
635
+ const historyLimitations = lifecycleLimitations([
636
+ ...(totalOmitted ? [`${totalOmitted} earlier action transition event${totalOmitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
637
+ ...(invalid ? [`${invalid} malformed or illegal action transition event${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
638
+ ...(duplicateConflict ? [`${duplicateConflict} conflicting duplicate event identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
639
+ ...(projected.conflicts ? [`${projected.conflicts} retained concurrent transition${projected.conflicts === 1 ? ' is' : 's are'} not applied to the current-state projection.`] : []),
640
+ ...retainedLimitations,
641
+ ]);
642
+ return {
643
+ history: projected.history,
644
+ state: projected.state,
645
+ omitted: totalOmitted,
646
+ historyLimitations,
647
+ };
648
+ }
649
+ function normalizeAction(raw, fallback, options = {}) {
650
+ const item = record(raw);
651
+ const recipient = text(item.recipient, MAX_RESPONSE_RECIPIENT_LENGTH);
652
+ if (!recipient)
653
+ return null;
654
+ const createdAt = iso(item.createdAt, fallback, options);
655
+ const actionId = safeId(item.id, 'action', { recipient, createdAt });
656
+ const history = normalizeActionHistory(item.history, item, actionId, createdAt, fallback, options);
657
+ const applied = history.history.filter((event) => event.applied);
658
+ const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
659
+ const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
660
+ const latestOutcomeDetail = [...applied].reverse().find((event) => event.outcomeDetail) ?? null;
661
+ const latestEventAt = applied.at(-1)?.occurredAt ?? createdAt;
662
+ const metadataUpdatedAt = iso(item.metadataUpdatedAt ?? item.updatedAt, createdAt, options);
663
+ const updatedAt = Date.parse(latestEventAt) > Date.parse(metadataUpdatedAt) ? latestEventAt : metadataUpdatedAt;
664
+ return {
665
+ id: actionId,
666
+ type: typeof item.type === 'string' && ACTION_TYPES.has(item.type)
667
+ ? item.type
668
+ : 'internal_review',
669
+ recipient,
670
+ contactSource: text(item.contactSource, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_supplied',
671
+ contactLimitations: limitations(item.contactLimitations),
672
+ dueAt: optionalIso(item.dueAt, options),
673
+ state: history.state,
674
+ reference: latestReference,
675
+ followUpAt: optionalIso(item.followUpAt, options),
676
+ providerOutcome: latestProviderOutcome?.providerOutcome ?? null,
677
+ outcome: latestProviderOutcome?.outcomeDetail ?? latestOutcomeDetail?.outcomeDetail ?? null,
678
+ originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
679
+ ? item.originActionId
680
+ : null,
681
+ history: history.history,
682
+ historyOmitted: history.omitted,
683
+ historyLimitations: history.historyLimitations,
684
+ createdAt,
685
+ metadataUpdatedAt,
686
+ updatedAt,
687
+ };
688
+ }
689
+ function mergeNormalizedActions(left, right, fallback) {
690
+ const metadataWinner = Date.parse(left.metadataUpdatedAt) > Date.parse(right.metadataUpdatedAt)
691
+ ? left
692
+ : Date.parse(right.metadataUpdatedAt) > Date.parse(left.metadataUpdatedAt)
693
+ ? right
694
+ : JSON.stringify(left) <= JSON.stringify(right) ? left : right;
695
+ return normalizeAction({
696
+ ...metadataWinner,
697
+ id: left.id,
698
+ createdAt: Date.parse(left.createdAt) <= Date.parse(right.createdAt) ? left.createdAt : right.createdAt,
699
+ history: [...left.history, ...right.history],
700
+ historyOmitted: Math.max(left.historyOmitted, right.historyOmitted),
701
+ historyLimitations: [...left.historyLimitations, ...right.historyLimitations],
702
+ }, fallback, { sourceVersion: 13 });
703
+ }
704
+ function boundActionCollection(actions, fallback) {
705
+ const keep = new Set();
706
+ for (const action of actions) {
707
+ const latest = action.history.at(-1);
708
+ if (latest)
709
+ keep.add(`${action.id}\u0000${latest.id}`);
710
+ }
711
+ const all = actions.flatMap((action) => action.history.map((event) => ({ action, event })))
712
+ .sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt)
713
+ || compareCodeUnits(right.event.id, left.event.id));
714
+ for (const item of all) {
715
+ if (keep.size >= MAX_CASE_ACTION_EVENTS_PER_CASE)
716
+ break;
717
+ keep.add(`${item.action.id}\u0000${item.event.id}`);
718
+ }
719
+ let bounded = actions.map((action) => {
720
+ const history = action.history.filter((event) => keep.has(`${action.id}\u0000${event.id}`));
721
+ const omitted = action.historyOmitted + action.history.length - history.length;
722
+ return normalizeAction({ ...action, history, historyOmitted: omitted }, fallback, { sourceVersion: 13 });
723
+ });
724
+ while (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE) {
725
+ const candidate = bounded
726
+ .flatMap((action) => action.history.slice(0, -1).map((event) => ({ action, event })))
727
+ .sort((left, right) => Date.parse(left.event.occurredAt) - Date.parse(right.event.occurredAt)
728
+ || compareCodeUnits(left.event.id, right.event.id))[0];
729
+ if (!candidate)
730
+ break;
731
+ bounded = bounded.map((action) => action.id !== candidate.action.id
732
+ ? action
733
+ : normalizeAction({
734
+ ...action,
735
+ history: action.history.filter((event) => event.id !== candidate.event.id),
736
+ historyOmitted: action.historyOmitted + 1,
737
+ }, fallback, { sourceVersion: 13 }));
738
+ }
739
+ if (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE && bounded.length > 1) {
740
+ const byteOmissionPattern = /^(\d+) earlier response actions? omitted by the per-Case response-history byte bound\.$/u;
741
+ const previouslyOmitted = bounded.reduce((maximum, action) => {
742
+ const retainedCount = action.historyLimitations.reduce((count, item) => {
743
+ const match = byteOmissionPattern.exec(item);
744
+ return match ? Math.max(count, Number(match[1])) : count;
745
+ }, 0);
746
+ return Math.max(maximum, retainedCount);
747
+ }, 0);
748
+ bounded = bounded.map((action) => ({
749
+ ...action,
750
+ historyLimitations: action.historyLimitations.filter((item) => !byteOmissionPattern.test(item)),
751
+ }));
752
+ let omittedActions = 0;
753
+ while (bounded.length > 1) {
754
+ bounded = bounded.slice(1);
755
+ omittedActions += 1;
756
+ const first = bounded[0];
757
+ const totalOmitted = previouslyOmitted + omittedActions;
758
+ bounded = [{
759
+ ...first,
760
+ historyLimitations: lifecycleLimitations([
761
+ `${totalOmitted} earlier response action${totalOmitted === 1 ? '' : 's'} omitted by the per-Case response-history byte bound.`,
762
+ ...first.historyLimitations,
763
+ ]),
764
+ }, ...bounded.slice(1)];
765
+ if (bytes(bounded) <= MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE)
766
+ return bounded;
767
+ }
768
+ }
769
+ return bounded;
770
+ }
771
+ export function normalizeCaseActions(raw, fallback, options = {}) {
772
+ if (!Array.isArray(raw))
773
+ return [];
774
+ const byId = new Map();
775
+ const inspected = raw.slice(0, MAX_CASE_ACTIONS * 2);
776
+ let invalid = 0;
777
+ for (const item of inspected) {
778
+ const normalized = normalizeAction(item, fallback, options);
779
+ if (!normalized) {
780
+ invalid += 1;
781
+ continue;
782
+ }
783
+ const existing = byId.get(normalized.id);
784
+ byId.set(normalized.id, existing ? mergeNormalizedActions(existing, normalized, fallback) : normalized);
785
+ }
786
+ const normalizedActions = [...byId.values()]
787
+ .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id));
788
+ const uninspected = Math.max(0, raw.length - inspected.length);
789
+ const omitted = Math.max(0, normalizedActions.length - MAX_CASE_ACTIONS);
790
+ const actions = normalizedActions.slice(-MAX_CASE_ACTIONS);
791
+ if (actions.length && (invalid || uninspected || omitted)) {
792
+ const first = actions[0];
793
+ actions[0] = {
794
+ ...first,
795
+ historyLimitations: lifecycleLimitations([
796
+ ...(invalid ? [`${invalid} malformed response-action candidate${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
797
+ ...(uninspected ? [`${uninspected} response-action candidate${uninspected === 1 ? '' : 's'} beyond the bounded inspection window were not traversed.`] : []),
798
+ ...(omitted ? [`${omitted} earlier response action${omitted === 1 ? '' : 's'} omitted by the per-Case action bound.`] : []),
799
+ ...first.historyLimitations,
800
+ ]),
801
+ };
802
+ }
803
+ const actionIds = new Set(actions.map((action) => action.id));
804
+ const linked = actions.map((action) => {
805
+ const originActionId = action.originActionId && actionIds.has(action.originActionId) && action.originActionId !== action.id
806
+ ? action.originActionId
807
+ : null;
808
+ const invalidOrigins = action.history.filter((event) => event.originActionId
809
+ && (!actionIds.has(event.originActionId) || event.originActionId === action.id)).length;
810
+ return {
811
+ ...action,
812
+ originActionId,
813
+ history: action.history.map((event) => ({
814
+ ...event,
815
+ originActionId: event.originActionId && actionIds.has(event.originActionId) && event.originActionId !== action.id
816
+ ? event.originActionId
817
+ : null,
818
+ })),
819
+ historyLimitations: lifecycleLimitations([
820
+ ...action.historyLimitations,
821
+ ...(invalidOrigins || (action.originActionId && !originActionId)
822
+ ? ['One or more dangling or self-referential origin-action links were omitted.']
823
+ : []),
824
+ ]),
825
+ };
826
+ });
827
+ return boundActionCollection(linked, fallback);
828
+ }
829
+ export function appendCaseAction(current, raw, now) {
830
+ if (current.length >= MAX_CASE_ACTIONS) {
831
+ throw new Error(`A Case can retain at most ${MAX_CASE_ACTIONS} response actions. No additional action was retained.`);
832
+ }
833
+ const item = record(raw);
834
+ const id = freshId('action');
835
+ const originActionId = typeof item.originActionId === 'string' && current.some((action) => action.id === item.originActionId)
836
+ ? item.originActionId
837
+ : null;
838
+ if (item.originActionId != null && !originActionId)
839
+ throw new Error('A follow-on action requires an existing originating action.');
840
+ const history = [{
841
+ id: freshId('action-event'),
842
+ previousState: null,
843
+ nextState: 'drafting',
844
+ occurredAt: now,
845
+ sourceClass: 'analyst',
846
+ provenance: 'browser_local_action_creation',
847
+ reference: null,
848
+ evidencePinId: null,
849
+ limitations: [],
850
+ providerOutcome: null,
851
+ outcomeDetail: null,
852
+ originActionId,
853
+ }];
854
+ const created = normalizeAction({
855
+ ...item,
856
+ id,
857
+ originActionId,
858
+ history,
859
+ historyOmitted: 0,
860
+ createdAt: now,
861
+ metadataUpdatedAt: now,
862
+ }, now, { sourceVersion: 13 });
863
+ if (!created)
864
+ throw new Error('An action requires a recipient or internal owner.');
865
+ return normalizeCaseActions([...current, created], now, { sourceVersion: 13 });
866
+ }
867
+ export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
868
+ const action = current.find((item) => item.id === actionId);
869
+ if (!action)
870
+ throw new Error('That case action no longer exists.');
871
+ const item = record(raw);
872
+ const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
873
+ ? item.nextState
874
+ : null;
875
+ const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
876
+ ? item.sourceClass
877
+ : 'analyst';
878
+ if (!nextState || !isLegalCaseActionTransition(action.state, nextState, sourceClass)) {
879
+ throw new Error(`The transition from ${action.state.replaceAll('_', ' ')} to ${String(item.nextState || 'that state').replaceAll('_', ' ')} is not permitted.`);
880
+ }
881
+ if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState) && sourceClass !== 'analyst') {
882
+ throw new Error('Readiness, review, authorisation, and submission require an explicit analyst transition.');
883
+ }
884
+ const originActionId = typeof item.originActionId === 'string'
885
+ && item.originActionId !== action.id
886
+ && current.some((candidate) => candidate.id === item.originActionId)
887
+ ? item.originActionId
888
+ : action.originActionId;
889
+ if (item.originActionId != null && originActionId !== item.originActionId) {
890
+ throw new Error('A referral or follow-on transition requires a distinct existing originating action.');
891
+ }
892
+ if (item.evidencePinId != null && (typeof item.evidencePinId !== 'string'
893
+ || !SAFE_ID_RE.test(item.evidencePinId) || (validPinIds && !validPinIds.has(item.evidencePinId)))) {
894
+ throw new Error('An action transition evidence pin must reference a retained Case evidence pin.');
895
+ }
896
+ const occurredAt = optionalIso(item.occurredAt) ?? now;
897
+ const event = normalizeActionEvent({
898
+ ...item,
899
+ id: freshId('action-event'),
900
+ previousState: action.state,
901
+ nextState,
902
+ occurredAt,
903
+ sourceClass,
904
+ provenance: text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || 'browser_local_explicit_transition',
905
+ originActionId,
906
+ }, action.id, currentActionNormalizationOptions(validPinIds));
907
+ if (!event)
908
+ throw new Error('The action transition contains an invalid time, provider outcome, or provenance field.');
909
+ const updated = normalizeAction({
910
+ ...action,
911
+ history: [...action.history, event],
912
+ historyOmitted: action.historyOmitted,
913
+ }, now, currentActionNormalizationOptions(validPinIds));
914
+ return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
915
+ }
916
+ export function updateCaseAction(current, raw, now, validPinIds) {
917
+ const patch = record(raw);
918
+ const id = typeof patch.id === 'string' && SAFE_ID_RE.test(patch.id) ? patch.id : '';
919
+ const existing = current.find((item) => item.id === id);
920
+ if (!existing)
921
+ throw new Error('That case action no longer exists.');
922
+ if (Object.hasOwn(patch, 'state') || Object.hasOwn(patch, 'outcome') || Object.hasOwn(patch, 'providerOutcome') || Object.hasOwn(patch, 'reference')) {
923
+ throw new Error('State, reference, and provider outcomes must be recorded as an append-only action transition.');
924
+ }
925
+ const metadata = {
926
+ ...existing,
927
+ type: Object.hasOwn(patch, 'type') ? patch.type : existing.type,
928
+ recipient: Object.hasOwn(patch, 'recipient') ? patch.recipient : existing.recipient,
929
+ contactSource: Object.hasOwn(patch, 'contactSource') ? patch.contactSource : existing.contactSource,
930
+ contactLimitations: Object.hasOwn(patch, 'contactLimitations') ? patch.contactLimitations : existing.contactLimitations,
931
+ dueAt: Object.hasOwn(patch, 'dueAt') ? patch.dueAt : existing.dueAt,
932
+ followUpAt: Object.hasOwn(patch, 'followUpAt') ? patch.followUpAt : existing.followUpAt,
933
+ originActionId: Object.hasOwn(patch, 'originActionId') ? patch.originActionId : existing.originActionId,
934
+ metadataUpdatedAt: now,
935
+ };
936
+ if (Object.hasOwn(patch, 'originActionId') && patch.originActionId !== null
937
+ && (typeof patch.originActionId !== 'string' || patch.originActionId === id || !current.some((item) => item.id === patch.originActionId))) {
938
+ throw new Error('A follow-on action requires a distinct existing originating action.');
939
+ }
940
+ let updated = normalizeAction(metadata, now, currentActionNormalizationOptions(validPinIds));
941
+ if (!updated)
942
+ throw new Error('An action requires a recipient or internal owner.');
943
+ const materialChanged = ['type', 'recipient', 'contactSource', 'contactLimitations', 'originActionId']
944
+ .some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
945
+ if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
946
+ throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
947
+ }
948
+ if (materialChanged && ['ready_for_review', 'reviewed', 'authorised'].includes(existing.state)) {
949
+ const invalidated = normalizeAction({
950
+ ...updated,
951
+ history: [...updated.history, {
952
+ id: freshId('action-event'),
953
+ previousState: existing.state,
954
+ nextState: 'drafting',
955
+ occurredAt: now,
956
+ sourceClass: 'browser_local',
957
+ provenance: 'material_action_change',
958
+ reference: null,
959
+ evidencePinId: null,
960
+ limitations: ['Material action inputs changed after review; prior readiness, review, or authorisation no longer applies.'],
961
+ providerOutcome: null,
962
+ outcomeDetail: null,
963
+ originActionId: updated.originActionId,
964
+ }],
965
+ }, now, currentActionNormalizationOptions(validPinIds));
966
+ if (!invalidated)
967
+ throw new Error('The material action change could not be retained safely.');
968
+ updated = invalidated;
969
+ }
970
+ const interim = current.map((item) => item.id === id ? updated : item);
971
+ return patch.transition !== undefined
972
+ ? appendCaseActionTransition(interim, id, patch.transition, now, validPinIds)
973
+ : normalizeCaseActions(interim, now, currentActionNormalizationOptions(validPinIds));
974
+ }
975
+ export function buildCaseActionOutcomeSummary(actions, nowRaw = new Date().toISOString()) {
976
+ const now = optionalIso(nowRaw) ?? new Date().toISOString();
977
+ const nowTime = Date.parse(now);
978
+ const active = actions.filter((item) => item.state !== 'terminal');
979
+ const latestOutcomes = actions
980
+ .flatMap((action) => action.history.filter((event) => event.applied && event.providerOutcome).map((event) => ({ action, event })))
981
+ .sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt) || compareCodeUnits(right.event.id, left.event.id))
982
+ .slice(0, 5)
983
+ .map(({ action, event }) => ({
984
+ actionId: action.id,
985
+ recipient: action.recipient,
986
+ state: action.state,
987
+ providerOutcome: event.providerOutcome,
988
+ outcomeDetail: event.outcomeDetail,
989
+ occurredAt: event.occurredAt,
990
+ }));
991
+ return {
992
+ total: actions.length,
993
+ active: active.length,
994
+ drafting: actions.filter((item) => item.state === 'drafting').length,
995
+ readyForReview: actions.filter((item) => item.state === 'ready_for_review').length,
996
+ reviewed: actions.filter((item) => item.state === 'reviewed').length,
997
+ authorised: actions.filter((item) => item.state === 'authorised').length,
998
+ submitted: actions.filter((item) => item.state === 'submitted').length,
999
+ acknowledged: actions.filter((item) => item.state === 'acknowledged').length,
1000
+ terminal: actions.filter((item) => item.state === 'terminal').length,
1001
+ overdue: active.filter((item) => item.dueAt && Date.parse(item.dueAt) < nowTime).length,
1002
+ followUpDue: active.filter((item) => item.followUpAt && Date.parse(item.followUpAt) <= nowTime).length,
1003
+ withProviderOutcome: actions.filter((item) => item.providerOutcome !== null).length,
1004
+ latestOutcomes,
1005
+ };
1006
+ }
1007
+ export function mergeCaseEvidencePins(local, imported, fallback) {
1008
+ return normalizeCaseEvidencePins([...local, ...imported], fallback);
1009
+ }
1010
+ export function mergeCaseDecisions(local, imported, fallback, validPinIds) {
1011
+ return normalizeCaseDecisions([...local, ...imported], fallback, validPinIds);
1012
+ }
1013
+ export function mergeCaseActions(local, imported, fallback, validPinIds) {
1014
+ return normalizeCaseActions([...local, ...imported], fallback, currentActionNormalizationOptions(validPinIds));
1015
+ }
1016
+ function assertionProvenanceList(value, maximum) {
1017
+ if (!Array.isArray(value))
1018
+ return [];
1019
+ const output = new Set();
1020
+ for (const item of value.slice(0, maximum * 2)) {
1021
+ const normalized = text(item, MAX_RESPONSE_LIMITATION_LENGTH);
1022
+ if (normalized)
1023
+ output.add(normalized);
1024
+ if (output.size >= maximum)
1025
+ break;
1026
+ }
1027
+ return [...output];
1028
+ }
1029
+ function normalizeAssertionProvenance(value, options = {}) {
1030
+ const item = record(value);
1031
+ const sourceName = text(item.sourceName, 120);
1032
+ const sourceDigestSha256 = text(item.sourceDigestSha256, 64).toLowerCase();
1033
+ const entityValue = text(item.entityValue, MAX_RESPONSE_VALUE_LENGTH);
1034
+ if (item.origin !== 'external_import'
1035
+ || typeof item.format !== 'string'
1036
+ || !ASSERTION_EXTERNAL_FORMATS.has(item.format)
1037
+ || typeof item.entityType !== 'string'
1038
+ || !ASSERTION_EXTERNAL_ENTITY_TYPES.has(item.entityType)
1039
+ || !sourceName
1040
+ || !/^[0-9a-f]{64}$/u.test(sourceDigestSha256)
1041
+ || !entityValue) {
1042
+ return null;
1043
+ }
1044
+ const confidence = typeof item.confidence === 'number'
1045
+ && Number.isInteger(item.confidence)
1046
+ && item.confidence >= 0
1047
+ && item.confidence <= 100
1048
+ ? item.confidence
1049
+ : null;
1050
+ return {
1051
+ origin: 'external_import',
1052
+ format: item.format,
1053
+ sourceName,
1054
+ sourceDigestSha256,
1055
+ publisher: text(item.publisher, 160) || null,
1056
+ externalId: text(item.externalId, 200) || null,
1057
+ entityType: item.entityType,
1058
+ entityValue,
1059
+ observedAt: optionalIso(item.observedAt, options),
1060
+ createdAt: optionalIso(item.createdAt, options),
1061
+ modifiedAt: optionalIso(item.modifiedAt, options),
1062
+ confidence,
1063
+ labels: assertionProvenanceList(item.labels, MAX_ASSERTION_PROVENANCE_LABELS),
1064
+ markings: assertionProvenanceList(item.markings, MAX_ASSERTION_PROVENANCE_MARKINGS),
1065
+ };
1066
+ }
1067
+ function normalizeAssertion(raw, fallback, validPinIds, options = {}) {
1068
+ const item = record(raw);
1069
+ const statement = text(item.statement, MAX_RESPONSE_RATIONALE_LENGTH);
1070
+ if (!statement)
1071
+ return null;
1072
+ const createdAt = iso(item.createdAt, fallback, options);
1073
+ const provenance = normalizeAssertionProvenance(item.provenance, options);
1074
+ const legacyIds = uniqueIds(item.evidencePinIds, validPinIds);
1075
+ const evidenceRelations = normalizeEvidenceRelations(item.evidenceRelations, legacyIds, validPinIds);
1076
+ return {
1077
+ id: safeId(item.id, 'assertion', { statement, createdAt }),
1078
+ kind: typeof item.kind === 'string' && ASSERTION_KINDS.has(item.kind)
1079
+ ? item.kind
1080
+ : 'hypothesis',
1081
+ statement,
1082
+ rationale: text(item.rationale, MAX_RESPONSE_RATIONALE_LENGTH) || null,
1083
+ evidencePinIds: evidenceRelations.map((relation) => relation.evidencePinId),
1084
+ evidenceRelations,
1085
+ state: typeof item.state === 'string' && ASSERTION_STATES.has(item.state)
1086
+ ? item.state
1087
+ : 'open',
1088
+ createdAt,
1089
+ updatedAt: iso(item.updatedAt, createdAt, options),
1090
+ ...(provenance ? { provenance } : {}),
1091
+ };
1092
+ }
1093
+ export function normalizeCaseAssertions(raw, fallback, validPinIds, options = {}) {
1094
+ if (!Array.isArray(raw))
1095
+ return [];
1096
+ const byId = new Map();
1097
+ for (const item of raw.slice(0, MAX_CASE_ASSERTIONS * 2)) {
1098
+ const normalized = normalizeAssertion(item, fallback, validPinIds, options);
1099
+ if (!normalized)
1100
+ continue;
1101
+ const existing = byId.get(normalized.id);
1102
+ if (!existing || Date.parse(normalized.updatedAt) >= Date.parse(existing.updatedAt)) {
1103
+ byId.set(normalized.id, normalized);
1104
+ }
1105
+ }
1106
+ return [...byId.values()]
1107
+ .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))
1108
+ .slice(-MAX_CASE_ASSERTIONS);
1109
+ }
1110
+ export function appendCaseAssertion(current, raw, now, validPinIds) {
1111
+ const item = record(raw);
1112
+ const created = normalizeAssertion({
1113
+ ...item,
1114
+ id: freshId('assertion'),
1115
+ createdAt: now,
1116
+ updatedAt: now,
1117
+ }, now, validPinIds);
1118
+ if (!created)
1119
+ throw new Error('An analyst assertion requires a statement.');
1120
+ return normalizeCaseAssertions([...current, created], now, validPinIds);
1121
+ }
1122
+ export function updateCaseAssertion(current, raw, now, validPinIds) {
1123
+ const patch = record(raw);
1124
+ const id = typeof patch.id === 'string' && SAFE_ID_RE.test(patch.id) ? patch.id : '';
1125
+ const existing = current.find((item) => item.id === id);
1126
+ if (!existing)
1127
+ throw new Error('That analyst assertion no longer exists.');
1128
+ const updated = normalizeAssertion({
1129
+ ...existing,
1130
+ ...patch,
1131
+ id,
1132
+ createdAt: existing.createdAt,
1133
+ updatedAt: now,
1134
+ }, now, validPinIds);
1135
+ if (!updated)
1136
+ throw new Error('An analyst assertion requires a statement.');
1137
+ return normalizeCaseAssertions(current.map((item) => item.id === id ? updated : item), now, validPinIds);
1138
+ }
1139
+ function normalizeManualTrailEvent(raw, fallback, options = {}) {
1140
+ const item = record(raw);
1141
+ const summary = text(item.summary, MAX_RESPONSE_RATIONALE_LENGTH);
1142
+ if (!summary)
1143
+ return null;
1144
+ const createdAt = iso(item.createdAt, fallback, options);
1145
+ return {
1146
+ id: safeId(item.id, 'trail', { summary, createdAt }),
1147
+ kind: typeof item.kind === 'string' && TRAIL_KINDS.has(item.kind)
1148
+ ? item.kind
1149
+ : 'review',
1150
+ summary,
1151
+ target: text(item.target, MAX_TRAIL_TARGET_LENGTH) || null,
1152
+ createdAt,
1153
+ };
1154
+ }
1155
+ export function normalizeCaseManualTrail(raw, fallback, options = {}) {
1156
+ if (!Array.isArray(raw))
1157
+ return [];
1158
+ const byId = new Map();
1159
+ for (const item of raw.slice(0, MAX_CASE_MANUAL_TRAIL_EVENTS * 2)) {
1160
+ const normalized = normalizeManualTrailEvent(item, fallback, options);
1161
+ if (normalized && !byId.has(normalized.id))
1162
+ byId.set(normalized.id, normalized);
1163
+ }
1164
+ return [...byId.values()]
1165
+ .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))
1166
+ .slice(-MAX_CASE_MANUAL_TRAIL_EVENTS);
1167
+ }
1168
+ export function appendCaseManualTrailEvent(current, raw, now) {
1169
+ const item = record(raw);
1170
+ const created = normalizeManualTrailEvent({ ...item, id: freshId('trail'), createdAt: now }, now);
1171
+ if (!created)
1172
+ throw new Error('An investigation-trail entry requires a summary.');
1173
+ return normalizeCaseManualTrail([...current, created], now);
1174
+ }
1175
+ export function mergeCaseAssertions(local, imported, fallback, validPinIds) {
1176
+ return normalizeCaseAssertions([...local, ...imported], fallback, validPinIds);
1177
+ }
1178
+ export function mergeCaseManualTrail(local, imported, fallback) {
1179
+ return normalizeCaseManualTrail([...local, ...imported], fallback);
1180
+ }
1181
+ function sightingSourceClass(state) {
1182
+ if (state === 'observed_by_deployment')
1183
+ return 'deployment';
1184
+ if (state === 'reported_by_provider')
1185
+ return 'provider';
1186
+ return 'analyst';
1187
+ }
1188
+ function normalizeCaseSighting(raw, fallback, validPinIds, options = {}) {
1189
+ const item = record(raw);
1190
+ if (typeof item.state !== 'string' || !SIGHTING_STATES.has(item.state))
1191
+ return null;
1192
+ const state = item.state;
1193
+ const source = text(item.source, MAX_RESPONSE_LABEL_LENGTH);
1194
+ if (!source)
1195
+ return null;
1196
+ const createdAt = iso(item.createdAt, fallback, options);
1197
+ const evidencePinId = typeof item.evidencePinId === 'string'
1198
+ && SAFE_ID_RE.test(item.evidencePinId)
1199
+ && (!validPinIds || validPinIds.has(item.evidencePinId))
1200
+ ? item.evidencePinId
1201
+ : null;
1202
+ return {
1203
+ id: safeId(item.id, 'sighting', { state, source, createdAt }),
1204
+ state,
1205
+ sourceClass: sightingSourceClass(state),
1206
+ category: typeof item.category === 'string' && SIGHTING_CATEGORIES.has(item.category)
1207
+ ? item.category
1208
+ : 'other',
1209
+ source,
1210
+ observedAt: iso(item.observedAt, createdAt, options),
1211
+ completeness: typeof item.completeness === 'string' && COMPLETENESS.has(item.completeness)
1212
+ ? item.completeness
1213
+ : 'unknown',
1214
+ evidencePinId,
1215
+ limitations: limitations(item.limitations),
1216
+ createdAt,
1217
+ };
1218
+ }
1219
+ export function normalizeCaseSightings(raw, fallback, validPinIds, options = {}) {
1220
+ if (!Array.isArray(raw))
1221
+ return [];
1222
+ const byId = new Map();
1223
+ for (const item of raw.slice(0, MAX_CASE_SIGHTINGS * 2)) {
1224
+ const normalized = normalizeCaseSighting(item, fallback, validPinIds, options);
1225
+ if (normalized && !byId.has(normalized.id))
1226
+ byId.set(normalized.id, normalized);
1227
+ }
1228
+ return [...byId.values()]
1229
+ .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt) || compareCodeUnits(left.id, right.id))
1230
+ .slice(-MAX_CASE_SIGHTINGS);
1231
+ }
1232
+ export function appendCaseSighting(current, raw, now, validPinIds) {
1233
+ const item = record(raw);
1234
+ const created = normalizeCaseSighting({
1235
+ ...item,
1236
+ id: freshId('sighting'),
1237
+ createdAt: now,
1238
+ }, now, validPinIds);
1239
+ if (!created)
1240
+ throw new Error('A sighting requires a source and explicit source-qualified state.');
1241
+ return normalizeCaseSightings([...current, created], now, validPinIds);
1242
+ }
1243
+ export function mergeCaseSightings(local, imported, fallback, validPinIds) {
1244
+ return normalizeCaseSightings([...local, ...imported], fallback, validPinIds);
1245
+ }
1246
+ function normalizeObservedEffectReview(raw, fallback, validPinIds, validSightingIds, options = {}) {
1247
+ const item = record(raw);
1248
+ if (typeof item.state !== 'string' || !OBSERVED_EFFECT_STATES.has(item.state))
1249
+ return null;
1250
+ if (typeof item.sourceClass !== 'string' || !OBSERVED_EFFECT_SOURCE_CLASSES.has(item.sourceClass))
1251
+ return null;
1252
+ const source = text(item.source, MAX_RESPONSE_LABEL_LENGTH);
1253
+ const observedAt = optionalIso(item.observedAt, options);
1254
+ if (!source || !observedAt)
1255
+ return null;
1256
+ const createdAt = iso(item.createdAt, observedAt || fallback, options);
1257
+ const evidencePinId = typeof item.evidencePinId === 'string'
1258
+ && SAFE_ID_RE.test(item.evidencePinId)
1259
+ && (!validPinIds || validPinIds.has(item.evidencePinId))
1260
+ ? item.evidencePinId
1261
+ : null;
1262
+ const sightingId = typeof item.sightingId === 'string'
1263
+ && SAFE_ID_RE.test(item.sightingId)
1264
+ && (!validSightingIds || validSightingIds.has(item.sightingId))
1265
+ ? item.sightingId
1266
+ : null;
1267
+ const linkLimitations = [
1268
+ ...(item.evidencePinId != null && !evidencePinId ? ['A malformed or dangling evidence-pin reference was omitted from this independent review.'] : []),
1269
+ ...(item.sightingId != null && !sightingId ? ['A malformed or dangling sighting reference was omitted from this independent review.'] : []),
1270
+ ];
1271
+ return {
1272
+ id: safeId(item.id, 'effect-review', { state: item.state, source, observedAt, createdAt }),
1273
+ state: item.state,
1274
+ observedAt,
1275
+ sourceClass: item.sourceClass,
1276
+ source,
1277
+ completeness: typeof item.completeness === 'string' && COMPLETENESS.has(item.completeness)
1278
+ ? item.completeness
1279
+ : 'unknown',
1280
+ limitations: limitations([...linkLimitations, ...limitations(item.limitations)]),
1281
+ evidencePinId,
1282
+ sightingId,
1283
+ followUpAt: optionalIso(item.followUpAt, options),
1284
+ createdAt,
1285
+ };
1286
+ }
1287
+ export function normalizeCaseObservedEffectHistory(raw, fallback, validPinIds, validSightingIds, options = {}) {
1288
+ const root = record(raw);
1289
+ const source = Array.isArray(raw) ? raw : Array.isArray(root.reviews) ? root.reviews : [];
1290
+ const byId = new Map();
1291
+ let invalid = 0;
1292
+ let duplicateConflict = 0;
1293
+ for (const item of source.slice(0, MAX_CASE_OBSERVED_EFFECT_REVIEWS * 4)) {
1294
+ const normalized = normalizeObservedEffectReview(item, fallback, validPinIds, validSightingIds, options);
1295
+ if (!normalized) {
1296
+ invalid += 1;
1297
+ continue;
1298
+ }
1299
+ const existing = byId.get(normalized.id);
1300
+ if (!existing)
1301
+ byId.set(normalized.id, normalized);
1302
+ else if (JSON.stringify(normalized) !== JSON.stringify(existing)) {
1303
+ duplicateConflict += 1;
1304
+ if (JSON.stringify(normalized) < JSON.stringify(existing))
1305
+ byId.set(normalized.id, normalized);
1306
+ }
1307
+ }
1308
+ const all = [...byId.values()].sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt) || compareCodeUnits(left.id, right.id));
1309
+ invalid += Math.max(0, source.length - MAX_CASE_OBSERVED_EFFECT_REVIEWS * 4);
1310
+ const omitted = boundedCounter(root.omitted)
1311
+ + invalid
1312
+ + duplicateConflict
1313
+ + Math.max(0, all.length - MAX_CASE_OBSERVED_EFFECT_REVIEWS);
1314
+ const preV13HistoryUnavailable = options.sourceVersion != null && options.sourceVersion <= 12
1315
+ ? true
1316
+ : root.preV13HistoryUnavailable === true;
1317
+ const retainedLimitations = limitations(root.limitations).filter((item) => !/^\d+ earlier observed-effect reviews? omitted by bounded retention\.$/u.test(item));
1318
+ return {
1319
+ reviews: all.slice(-MAX_CASE_OBSERVED_EFFECT_REVIEWS),
1320
+ omitted,
1321
+ preV13HistoryUnavailable,
1322
+ limitations: lifecycleLimitations([
1323
+ ...(omitted ? [`${omitted} earlier observed-effect review${omitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
1324
+ ...(invalid ? [`${invalid} malformed or unlinked observed-effect review${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
1325
+ ...(duplicateConflict ? [`${duplicateConflict} conflicting observed-effect review identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
1326
+ ...(preV13HistoryUnavailable ? ['Migrated from a pre-v13 Case; earlier independent observed-effect review history is unavailable.'] : []),
1327
+ 'Observed-effect reviews are independent point-in-time records; provider workflow events do not create or replace them.',
1328
+ ...retainedLimitations,
1329
+ ]),
1330
+ };
1331
+ }
1332
+ export function appendCaseObservedEffectReview(current, raw, now, validPinIds, validSightingIds) {
1333
+ const item = record(raw);
1334
+ const created = normalizeObservedEffectReview({ ...item, id: freshId('effect-review'), createdAt: now }, now, validPinIds, validSightingIds);
1335
+ if (!created)
1336
+ throw new Error('An independent observed-effect review requires a valid state, time, source class, and source.');
1337
+ return normalizeCaseObservedEffectHistory({
1338
+ reviews: [...current.reviews, created],
1339
+ omitted: current.omitted,
1340
+ preV13HistoryUnavailable: current.preV13HistoryUnavailable,
1341
+ limitations: current.limitations,
1342
+ }, now, validPinIds, validSightingIds);
1343
+ }
1344
+ export function mergeCaseObservedEffectHistories(local, imported, fallback, validPinIds, validSightingIds) {
1345
+ return normalizeCaseObservedEffectHistory({
1346
+ reviews: [...local.reviews, ...imported.reviews],
1347
+ omitted: Math.max(local.omitted, imported.omitted),
1348
+ preV13HistoryUnavailable: local.preV13HistoryUnavailable || imported.preV13HistoryUnavailable,
1349
+ limitations: [...local.limitations, ...imported.limitations],
1350
+ }, fallback, validPinIds, validSightingIds);
1351
+ }
1352
+ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options = {}, linkContext = {}) {
1353
+ const item = record(raw);
1354
+ if (typeof item.reason !== 'string' || !CLOSURE_REASONS.has(item.reason))
1355
+ return null;
1356
+ const reason = item.reason;
1357
+ const summary = text(item.summary, MAX_RESPONSE_RATIONALE_LENGTH);
1358
+ if (!summary)
1359
+ return null;
1360
+ const createdAt = iso(item.createdAt, fallback, options);
1361
+ const observedEffectReviewId = typeof item.observedEffectReviewId === 'string'
1362
+ && SAFE_ID_RE.test(item.observedEffectReviewId)
1363
+ && (!validReviewIds || validReviewIds.has(item.observedEffectReviewId))
1364
+ ? item.observedEffectReviewId
1365
+ : null;
1366
+ const actionId = typeof item.actionId === 'string'
1367
+ && SAFE_ID_RE.test(item.actionId)
1368
+ && (!validActionIds || validActionIds.has(item.actionId))
1369
+ ? item.actionId
1370
+ : null;
1371
+ const linkedReview = observedEffectReviewId ? linkContext.reviewEvidence?.get(observedEffectReviewId) : undefined;
1372
+ const reviewPredatesClosure = linkedReview !== undefined
1373
+ && Date.parse(linkedReview.observedAt) <= Date.parse(createdAt)
1374
+ && Date.parse(linkedReview.createdAt) <= Date.parse(createdAt);
1375
+ if (linkContext.reviewEvidence && reason === 'independently_not_reproduced'
1376
+ && (!reviewPredatesClosure || linkedReview?.state !== 'not_reproduced'))
1377
+ return null;
1378
+ if (linkContext.reviewEvidence && reason === 'infrastructure_changed'
1379
+ && (!reviewPredatesClosure || linkedReview?.state !== 'changed'))
1380
+ return null;
1381
+ const linkedProviderEvents = actionId ? linkContext.providerResolutionEvents?.get(actionId) ?? [] : [];
1382
+ if (linkContext.providerResolutionEvents && reason === 'provider_reported_resolution_not_independently_checked'
1383
+ && !linkedProviderEvents.some((event) => Date.parse(event.occurredAt) <= Date.parse(createdAt)))
1384
+ return null;
1385
+ const linkLimitations = [
1386
+ ...(item.observedEffectReviewId != null && !observedEffectReviewId ? ['A malformed or dangling observed-effect review reference was omitted from this closure.'] : []),
1387
+ ...(item.actionId != null && !actionId ? ['A malformed or dangling response-action reference was omitted from this closure.'] : []),
1388
+ ];
1389
+ return {
1390
+ id: safeId(item.id, 'case-closure', { reason: item.reason, summary, createdAt }),
1391
+ reason,
1392
+ summary,
1393
+ observedEffectReviewId,
1394
+ actionId,
1395
+ limitations: limitations([...linkLimitations, ...limitations(item.limitations)]),
1396
+ createdAt,
1397
+ };
1398
+ }
1399
+ export function buildCaseClosureLinkContext(observedEffects, actions) {
1400
+ return {
1401
+ reviewEvidence: new Map(observedEffects.reviews.map((review) => [review.id, {
1402
+ state: review.state,
1403
+ observedAt: review.observedAt,
1404
+ createdAt: review.createdAt,
1405
+ }])),
1406
+ providerResolutionEvents: new Map(actions.map((action) => [action.id, action.history
1407
+ .filter((event) => event.applied && event.providerOutcome === 'provider_reports_resolved')
1408
+ .map((event) => ({ eventId: event.id, occurredAt: event.occurredAt }))])),
1409
+ };
1410
+ }
1411
+ export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, validActionIds, options = {}, linkContext = {}) {
1412
+ const root = record(raw);
1413
+ const source = Array.isArray(raw) ? raw : Array.isArray(root.records) ? root.records : [];
1414
+ const byId = new Map();
1415
+ let invalid = 0;
1416
+ let duplicateConflict = 0;
1417
+ for (const item of source.slice(0, MAX_CASE_CLOSURES * 4)) {
1418
+ const normalized = normalizeClosure(item, fallback, validReviewIds, validActionIds, options, linkContext);
1419
+ if (!normalized) {
1420
+ invalid += 1;
1421
+ continue;
1422
+ }
1423
+ const existing = byId.get(normalized.id);
1424
+ if (!existing)
1425
+ byId.set(normalized.id, normalized);
1426
+ else if (JSON.stringify(normalized) !== JSON.stringify(existing)) {
1427
+ duplicateConflict += 1;
1428
+ if (JSON.stringify(normalized) < JSON.stringify(existing))
1429
+ byId.set(normalized.id, normalized);
1430
+ }
1431
+ }
1432
+ const all = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id));
1433
+ invalid += Math.max(0, source.length - MAX_CASE_CLOSURES * 4);
1434
+ const omitted = boundedCounter(root.omitted)
1435
+ + invalid
1436
+ + duplicateConflict
1437
+ + Math.max(0, all.length - MAX_CASE_CLOSURES);
1438
+ const preV13HistoryUnavailable = options.sourceVersion != null && options.sourceVersion <= 12
1439
+ ? true
1440
+ : root.preV13HistoryUnavailable === true;
1441
+ const retainedLimitations = limitations(root.limitations).filter((item) => !/^\d+ earlier closure records? omitted by bounded retention\.$/u.test(item));
1442
+ return {
1443
+ records: all.slice(-MAX_CASE_CLOSURES),
1444
+ omitted,
1445
+ preV13HistoryUnavailable,
1446
+ limitations: lifecycleLimitations([
1447
+ ...(omitted ? [`${omitted} earlier closure record${omitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
1448
+ ...(invalid ? [`${invalid} malformed, unsupported, or unlinked closure record${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
1449
+ ...(duplicateConflict ? [`${duplicateConflict} conflicting closure record identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
1450
+ ...(preV13HistoryUnavailable ? ['Migrated from a pre-v13 Case; earlier deliberate closure history is unavailable.'] : []),
1451
+ 'Closure records are deliberate analyst actions and do not establish absence, safety, provider performance, or legal sufficiency.',
1452
+ ...retainedLimitations,
1453
+ ]),
1454
+ };
1455
+ }
1456
+ export function appendCaseClosure(current, raw, now, observedEffects, actions) {
1457
+ const item = record(raw);
1458
+ const reason = typeof item.reason === 'string' && CLOSURE_REASONS.has(item.reason)
1459
+ ? item.reason
1460
+ : null;
1461
+ const review = typeof item.observedEffectReviewId === 'string'
1462
+ ? observedEffects.reviews.find((candidate) => candidate.id === item.observedEffectReviewId) ?? null
1463
+ : null;
1464
+ const action = typeof item.actionId === 'string'
1465
+ ? actions.find((candidate) => candidate.id === item.actionId) ?? null
1466
+ : null;
1467
+ if (reason === 'independently_not_reproduced'
1468
+ && (review?.state !== 'not_reproduced' || Date.parse(review.observedAt) > Date.parse(now) || Date.parse(review.createdAt) > Date.parse(now))) {
1469
+ throw new Error('This closure reason requires a linked independent not-reproduced review.');
1470
+ }
1471
+ if (reason === 'infrastructure_changed'
1472
+ && (review?.state !== 'changed' || Date.parse(review.observedAt) > Date.parse(now) || Date.parse(review.createdAt) > Date.parse(now))) {
1473
+ throw new Error('This closure reason requires a linked independent changed review.');
1474
+ }
1475
+ if (reason === 'provider_reported_resolution_not_independently_checked'
1476
+ && (action?.providerOutcome !== 'provider_reports_resolved'
1477
+ || !action.history.some((event) => event.applied
1478
+ && event.providerOutcome === 'provider_reports_resolved'
1479
+ && Date.parse(event.occurredAt) <= Date.parse(now)))) {
1480
+ throw new Error('This closure reason requires a linked typed provider-reported-resolution outcome.');
1481
+ }
1482
+ const linkContext = buildCaseClosureLinkContext(observedEffects, actions);
1483
+ const created = normalizeClosure({ ...item, id: freshId('case-closure'), createdAt: now }, now, new Set(observedEffects.reviews.map((candidate) => candidate.id)), new Set(actions.map((candidate) => candidate.id)), {}, linkContext);
1484
+ if (!created || !reason)
1485
+ throw new Error('A deliberate closure requires a typed reason and summary.');
1486
+ return normalizeCaseClosureHistory({
1487
+ records: [...current.records, created],
1488
+ omitted: current.omitted,
1489
+ preV13HistoryUnavailable: current.preV13HistoryUnavailable,
1490
+ limitations: current.limitations,
1491
+ }, now, new Set(observedEffects.reviews.map((candidate) => candidate.id)), new Set(actions.map((candidate) => candidate.id)), {}, linkContext);
1492
+ }
1493
+ export function mergeCaseClosureHistories(local, imported, fallback, validReviewIds, validActionIds, linkContext = {}) {
1494
+ return normalizeCaseClosureHistory({
1495
+ records: [...local.records, ...imported.records],
1496
+ omitted: Math.max(local.omitted, imported.omitted),
1497
+ preV13HistoryUnavailable: local.preV13HistoryUnavailable || imported.preV13HistoryUnavailable,
1498
+ limitations: [...local.limitations, ...imported.limitations],
1499
+ }, fallback, validReviewIds, validActionIds, {}, linkContext);
1500
+ }
1501
+ export function buildCaseResponseLifecycleSummary(input) {
1502
+ const providerEvents = (input.actions ?? []).flatMap((action) => action.history
1503
+ .filter((event) => event.providerOutcome)
1504
+ .map((event) => ({ action, event })));
1505
+ providerEvents.sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt)
1506
+ || compareCodeUnits(right.event.id, left.event.id));
1507
+ const providerTime = providerEvents[0]?.event.occurredAt ?? null;
1508
+ const providerEventsAtLatestTime = providerTime === null
1509
+ ? []
1510
+ : providerEvents.filter(({ event }) => event.occurredAt === providerTime);
1511
+ const latestProvider = providerEventsAtLatestTime.length === 1 && providerEventsAtLatestTime[0].event.applied
1512
+ ? providerEventsAtLatestTime[0]
1513
+ : null;
1514
+ const providerOutcomeState = providerEvents.length === 0
1515
+ ? 'missing'
1516
+ : latestProvider ? 'available' : 'ambiguous';
1517
+ const reviews = [...(input.observedEffects?.reviews ?? [])]
1518
+ .sort((left, right) => Date.parse(right.observedAt) - Date.parse(left.observedAt) || compareCodeUnits(right.id, left.id));
1519
+ const latestObserved = reviews[0] ?? null;
1520
+ const changedReviews = reviews.filter((review) => review.state === 'changed');
1521
+ const changedTime = changedReviews[0]?.observedAt ?? null;
1522
+ const changedAtLatestTime = changedTime === null
1523
+ ? []
1524
+ : changedReviews.filter((review) => review.observedAt === changedTime);
1525
+ const latestObservedChangeAt = changedAtLatestTime.length === 1 ? changedTime : null;
1526
+ const observedChangeState = changedReviews.length === 0
1527
+ ? 'missing'
1528
+ : latestObservedChangeAt ? 'available' : 'ambiguous';
1529
+ const latestClosure = [...(input.closures?.records ?? [])]
1530
+ .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || compareCodeUnits(right.id, left.id))[0] ?? null;
1531
+ return {
1532
+ providerOutcomeState,
1533
+ latestProviderOutcome: latestProvider ? {
1534
+ actionId: latestProvider.action.id,
1535
+ eventId: latestProvider.event.id,
1536
+ outcome: latestProvider.event.providerOutcome,
1537
+ occurredAt: latestProvider.event.occurredAt,
1538
+ reference: latestProvider.event.reference,
1539
+ } : null,
1540
+ observedChangeState,
1541
+ latestObservedEffect: latestObserved ? {
1542
+ reviewId: latestObserved.id,
1543
+ state: latestObserved.state,
1544
+ observedAt: latestObserved.observedAt,
1545
+ sourceClass: latestObserved.sourceClass,
1546
+ source: latestObserved.source,
1547
+ } : null,
1548
+ latestObservedChangeAt,
1549
+ latestClosure,
1550
+ };
1551
+ }
1552
+ export function buildCaseInvestigationTrail(input) {
1553
+ return [
1554
+ ...(input.assertions ?? []).map((item) => ({
1555
+ id: `assertion:${item.id}`,
1556
+ kind: 'assertion',
1557
+ label: `${item.kind.replaceAll('_', ' ')} · ${item.state}`,
1558
+ detail: item.statement,
1559
+ createdAt: item.updatedAt,
1560
+ })),
1561
+ ...(input.decisions ?? []).map((item) => ({
1562
+ id: `decision:${item.id}`,
1563
+ kind: 'decision',
1564
+ label: 'analyst decision',
1565
+ detail: item.summary,
1566
+ createdAt: item.createdAt,
1567
+ })),
1568
+ ...(input.actions ?? []).map((item) => ({
1569
+ id: `action:${item.id}`,
1570
+ kind: 'action',
1571
+ label: `${item.type.replaceAll('_', ' ')} · ${item.state}`,
1572
+ detail: item.recipient,
1573
+ createdAt: item.updatedAt,
1574
+ })),
1575
+ ...(input.manualTrail ?? []).map((item) => ({
1576
+ id: `manual:${item.id}`,
1577
+ kind: 'manual',
1578
+ label: item.kind.replaceAll('_', ' '),
1579
+ detail: item.target ? `${item.summary} · ${item.target}` : item.summary,
1580
+ createdAt: item.createdAt,
1581
+ })),
1582
+ ...(input.sightings ?? []).map((item) => ({
1583
+ id: `sighting:${item.id}`,
1584
+ kind: 'sighting',
1585
+ label: `${item.state.replaceAll('_', ' ')} · ${item.category}`,
1586
+ detail: `${item.source} · ${item.completeness}`,
1587
+ createdAt: item.observedAt,
1588
+ })),
1589
+ ...(input.observedEffects?.reviews ?? []).map((item) => ({
1590
+ id: `observed-effect:${item.id}`,
1591
+ kind: 'observed_effect',
1592
+ label: `independent effect · ${item.state.replaceAll('_', ' ')}`,
1593
+ detail: `${item.source} · ${item.completeness}`,
1594
+ createdAt: item.observedAt,
1595
+ })),
1596
+ ...(input.closures?.records ?? []).map((item) => ({
1597
+ id: `closure:${item.id}`,
1598
+ kind: 'closure',
1599
+ label: `case closure · ${item.reason.replaceAll('_', ' ')}`,
1600
+ detail: item.summary,
1601
+ createdAt: item.createdAt,
1602
+ })),
1603
+ ].sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || compareCodeUnits(left.id, right.id));
1604
+ }