@slicedearth/whoisleuth-cli 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +83 -61
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -2,114 +2,17 @@
2
2
  // deliberately separate from collected evidence snapshots: a pin describes a
3
3
  // fact selected by an analyst, a decision records analyst reasoning, and an
4
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, CASE_SCHEMA_VERSION, } 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
- 'platform_report',
17
- 'defensive_control',
18
- 'internal_review',
19
- ];
20
- export const CASE_DECISION_CONFIDENCE_LEVELS = ['unknown', 'low', 'moderate', 'high'];
21
- export const CASE_ACTION_STATES = [
22
- 'drafting',
23
- 'ready_for_review',
24
- 'reviewed',
25
- 'authorised',
26
- 'submitted',
27
- 'acknowledged',
28
- 'terminal',
29
- ];
30
- export const CASE_PROVIDER_OUTCOMES = [
31
- 'accepted_for_review',
32
- 'more_information_requested',
33
- 'referred_elsewhere',
34
- 'rejected_outside_policy_scope',
35
- 'no_response',
36
- 'partially_remediated',
37
- 'provider_reports_resolved',
38
- 'withdrawn',
39
- 'duplicate',
40
- ];
41
- export const CASE_ACTION_EVENT_SOURCE_CLASSES = [
42
- 'analyst',
43
- 'provider',
44
- 'browser_local',
45
- 'import',
46
- 'migration',
47
- ];
48
- export const CASE_OBSERVED_EFFECT_STATES = [
49
- 'not_checked',
50
- 'still_observed',
51
- 'changed',
52
- 'not_reproduced',
53
- 'unavailable',
54
- ];
55
- export const CASE_OBSERVED_EFFECT_SOURCE_CLASSES = ['deployment', 'analyst', 'import'];
56
- export const CASE_CLOSURE_REASONS = [
57
- 'independently_not_reproduced',
58
- 'infrastructure_changed',
59
- 'provider_reported_resolution_not_independently_checked',
60
- 'risk_accepted',
61
- 'false_positive',
62
- 'duplicate',
63
- 'unable_to_proceed',
64
- 'monitoring_transferred',
65
- ];
66
- export const CASE_ASSERTION_KINDS = [
67
- 'verified_fact',
68
- 'hypothesis',
69
- 'unknown',
70
- 'contradiction',
71
- 'next_step',
72
- ];
73
- export const CASE_ASSERTION_STATES = ['open', 'resolved'];
74
- export const CASE_ASSERTION_EXTERNAL_FORMATS = ['stix', 'misp'];
75
- export const CASE_ASSERTION_EXTERNAL_ENTITY_TYPES = [
76
- 'asn',
77
- 'certificate',
78
- 'domain',
79
- 'hostname',
80
- 'ipv4',
81
- 'ipv6',
82
- 'url',
83
- ];
84
- export const CASE_MANUAL_TRAIL_KINDS = ['pivot', 'review', 'handoff'];
85
- export const CASE_SIGHTING_STATES = [
86
- 'observed_by_deployment',
87
- 'reported_by_provider',
88
- 'analyst_confirmed',
89
- 'not_reproduced',
90
- 'expired',
91
- ];
92
- export const CASE_SIGHTING_CATEGORIES = [
93
- 'registration',
94
- 'delegation',
95
- 'certificate',
96
- 'mail',
97
- 'website',
98
- 'infrastructure',
99
- 'other',
100
- ];
101
- const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/u;
102
- const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
103
- const COMPLETENESS = new Set(CASE_PIN_COMPLETENESS);
5
+ import { CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, } from "../contracts/case-portability.mjs";
6
+ import { CASE_ASSERTION_EXTERNAL_ENTITY_TYPES, CASE_ASSERTION_EXTERNAL_FORMATS, CASE_ASSERTION_KINDS, CASE_ASSERTION_STATES, CASE_DECISION_CONFIDENCE_LEVELS, CASE_EVIDENCE_RELATION_STANCES, CASE_MANUAL_TRAIL_KINDS, CASE_SIGHTING_CATEGORIES, CASE_SIGHTING_STATES, CASE_TRANSITION_EXPECTATIONS, } from "./case-response-records.mjs";
7
+ import { COMPLETENESS, SAFE_ID_RE, compareCodeUnits, freshId, iso, limitations, optionalIso, record, safeId, text, uniqueIds, } from "./case-response-values.mjs";
8
+ import { isValidAsciiHostname } from "../../lib/hostname.mjs";
9
+ import { readCaseRecheckContext } from "./case-recheck-model.mjs";
10
+ export * from "./case-response-records.mjs";
11
+ export * from "./case-response-actions.mjs";
12
+ export * from "./case-response-outcomes.mjs";
13
+ 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, } from "../contracts/case-portability.mjs";
104
14
  const TRANSITION_EXPECTATIONS = new Set(CASE_TRANSITION_EXPECTATIONS);
105
- const ACTION_TYPES = new Set(CASE_ACTION_TYPES);
106
15
  const DECISION_CONFIDENCE_LEVELS = new Set(CASE_DECISION_CONFIDENCE_LEVELS);
107
- const ACTION_STATES = new Set(CASE_ACTION_STATES);
108
- const PROVIDER_OUTCOMES = new Set(CASE_PROVIDER_OUTCOMES);
109
- const ACTION_EVENT_SOURCE_CLASSES = new Set(CASE_ACTION_EVENT_SOURCE_CLASSES);
110
- const OBSERVED_EFFECT_STATES = new Set(CASE_OBSERVED_EFFECT_STATES);
111
- const OBSERVED_EFFECT_SOURCE_CLASSES = new Set(CASE_OBSERVED_EFFECT_SOURCE_CLASSES);
112
- const CLOSURE_REASONS = new Set(CASE_CLOSURE_REASONS);
113
16
  const ASSERTION_KINDS = new Set(CASE_ASSERTION_KINDS);
114
17
  const ASSERTION_STATES = new Set(CASE_ASSERTION_STATES);
115
18
  const EVIDENCE_RELATION_STANCES = new Set(CASE_EVIDENCE_RELATION_STANCES);
@@ -119,71 +22,6 @@ const TRAIL_KINDS = new Set(CASE_MANUAL_TRAIL_KINDS);
119
22
  const SIGHTING_STATES = new Set(CASE_SIGHTING_STATES);
120
23
  const SIGHTING_CATEGORIES = new Set(CASE_SIGHTING_CATEGORIES);
121
24
  const SHA256_RE = /^[a-f0-9]{64}$/u;
122
- function currentActionNormalizationOptions(validEvidencePinIds) {
123
- return validEvidencePinIds
124
- ? { sourceVersion: CASE_SCHEMA_VERSION, validEvidencePinIds }
125
- : { sourceVersion: CASE_SCHEMA_VERSION };
126
- }
127
- function record(value) {
128
- return value && typeof value === 'object' && !Array.isArray(value)
129
- ? value
130
- : {};
131
- }
132
- function text(value, maximum) {
133
- if (typeof value !== 'string')
134
- return '';
135
- return value.replace(CONTROL_REPLACE_RE, ' ').trim().slice(0, maximum);
136
- }
137
- function timestamp(value, options = {}) {
138
- return normalizeExplicitIsoTimestamp(value)
139
- ?? (options.legacyTimestamps ? normalizeLegacyIsoTimestamp(value) : null);
140
- }
141
- function iso(value, fallback, options = {}) {
142
- return timestamp(value, options) ?? fallback;
143
- }
144
- function optionalIso(value, options = {}) {
145
- return timestamp(value, options);
146
- }
147
- function hash(value) {
148
- let result = 2166136261 >>> 0;
149
- for (let index = 0; index < value.length; index += 1) {
150
- result ^= value.charCodeAt(index);
151
- result = Math.imul(result, 16777619);
152
- }
153
- return (result >>> 0).toString(36);
154
- }
155
- function deterministicId(prefix, value) {
156
- return `${prefix}-${hash(JSON.stringify(value))}`;
157
- }
158
- function freshId(prefix) {
159
- if (typeof globalThis.crypto?.randomUUID === 'function')
160
- return globalThis.crypto.randomUUID();
161
- return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
162
- }
163
- function safeId(value, prefix, raw) {
164
- return typeof value === 'string' && SAFE_ID_RE.test(value)
165
- ? value
166
- : deterministicId(prefix, raw);
167
- }
168
- function limitations(value) {
169
- if (!Array.isArray(value))
170
- return [];
171
- const unique = new Set();
172
- for (const item of value.slice(0, MAX_RESPONSE_LIMITATIONS * 2)) {
173
- const normalized = text(item, MAX_RESPONSE_LIMITATION_LENGTH);
174
- if (normalized)
175
- unique.add(normalized);
176
- if (unique.size >= MAX_RESPONSE_LIMITATIONS)
177
- break;
178
- }
179
- return [...unique];
180
- }
181
- function compareCodeUnits(left, right) {
182
- return left < right ? -1 : left > right ? 1 : 0;
183
- }
184
- function lifecycleLimitations(value) {
185
- return limitations(value).sort(compareCodeUnits);
186
- }
187
25
  function sourceSchema(value) {
188
26
  const item = record(value);
189
27
  const collection = text(item.collection, 80);
@@ -244,19 +82,6 @@ function certificateObservation(value, pin, options = {}) {
244
82
  namesComplete: item.namesComplete,
245
83
  };
246
84
  }
247
- function uniqueIds(value, validIds) {
248
- if (!Array.isArray(value))
249
- return [];
250
- const unique = new Set();
251
- for (const item of value.slice(0, MAX_DECISION_PIN_REFERENCES * 2)) {
252
- if (typeof item !== 'string' || !SAFE_ID_RE.test(item) || (validIds && !validIds.has(item)))
253
- continue;
254
- unique.add(item);
255
- if (unique.size >= MAX_DECISION_PIN_REFERENCES)
256
- break;
257
- }
258
- return [...unique];
259
- }
260
85
  function normalizeEvidenceRelations(value, legacyIds, validIds) {
261
86
  const output = new Map();
262
87
  if (Array.isArray(value)) {
@@ -302,7 +127,7 @@ function normalizePin(raw, fallback, options = {}) {
302
127
  source: text(item.source, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_selected',
303
128
  sourceState: text(item.sourceState, 40) || null,
304
129
  sourceSchema: normalizedSourceSchema,
305
- observedAt: iso(item.observedAt, createdAt, options),
130
+ observedAt: optionalIso(item.observedAt, options),
306
131
  collectionDepth: item.collectionDepth === 'deep' || item.collectionDepth === 'fast'
307
132
  ? item.collectionDepth
308
133
  : 'unknown',
@@ -324,6 +149,24 @@ function normalizePin(raw, fallback, options = {}) {
324
149
  value,
325
150
  sourceSchema: normalizedSourceSchema,
326
151
  }, options);
152
+ if ((options.sourceVersion ?? CASE_SCHEMA_VERSION) > PUBLISHED_V2_3_CASE_SCHEMA_VERSION
153
+ && item.observationHostname !== undefined) {
154
+ if (typeof item.observationHostname !== 'string' || !isValidAsciiHostname(item.observationHostname)
155
+ || item.observationHostname !== item.observationHostname.toLowerCase())
156
+ return null;
157
+ normalized.observationHostname = item.observationHostname;
158
+ }
159
+ if ((options.sourceVersion ?? CASE_SCHEMA_VERSION) > PUBLISHED_V2_3_CASE_SCHEMA_VERSION && item.webObservationMode !== undefined) {
160
+ if (item.webObservationMode !== 'selected_url')
161
+ return null;
162
+ normalized.webObservationMode = 'selected_url';
163
+ }
164
+ // Introduced in Case 15. Older public records must not acquire an identity
165
+ // merely because an unrecognised input field resembles a digest.
166
+ if ((options.sourceVersion ?? CASE_SCHEMA_VERSION) >= 15
167
+ && typeof item.importContentSha256 === 'string' && SHA256_RE.test(item.importContentSha256)) {
168
+ normalized.importContentSha256 = item.importContentSha256;
169
+ }
327
170
  return normalized;
328
171
  }
329
172
  export function normalizeCaseEvidencePins(raw, fallback, options = {}) {
@@ -407,644 +250,12 @@ export function appendCaseDecision(current, raw, now, validPinIds) {
407
250
  throw new Error('A decision requires a summary and rationale.');
408
251
  return normalizeCaseDecisions([...current, created], now, validPinIds);
409
252
  }
410
- const LEGACY_ACTION_STATE_MAP = Object.freeze({
411
- planned: 'drafting',
412
- ready_for_review: 'ready_for_review',
413
- submitted: 'submitted',
414
- acknowledged: 'acknowledged',
415
- resolved: 'terminal',
416
- closed: 'terminal',
417
- });
418
- const ACTION_TRANSITIONS = Object.freeze({
419
- drafting: new Set(['ready_for_review', 'terminal']),
420
- ready_for_review: new Set(['drafting', 'reviewed', 'terminal']),
421
- reviewed: new Set(['drafting', 'authorised', 'terminal']),
422
- authorised: new Set(['drafting', 'submitted', 'terminal']),
423
- submitted: new Set(['submitted', 'acknowledged', 'terminal']),
424
- acknowledged: new Set(['acknowledged', 'terminal']),
425
- terminal: new Set(),
426
- });
427
- function boundedCounter(value) {
428
- return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
429
- ? Math.min(value, 1_000_000)
430
- : 0;
431
- }
432
- function bytes(value) {
433
- try {
434
- return new TextEncoder().encode(JSON.stringify(value)).byteLength;
435
- }
436
- catch {
437
- return Number.POSITIVE_INFINITY;
438
- }
439
- }
440
- export function isLegalCaseActionTransition(previousState, nextState, sourceClass = 'analyst') {
441
- if (previousState === null) {
442
- return sourceClass === 'migration'
443
- || nextState === 'drafting' && (sourceClass === 'analyst' || sourceClass === 'browser_local');
444
- }
445
- if (!ACTION_TRANSITIONS[previousState].has(nextState))
446
- return false;
447
- if (sourceClass === 'provider' || sourceClass === 'import') {
448
- return (previousState === 'submitted' || previousState === 'acknowledged')
449
- && (nextState === 'submitted' || nextState === 'acknowledged' || nextState === 'terminal');
450
- }
451
- if (sourceClass === 'browser_local') {
452
- return nextState === 'drafting' && ['ready_for_review', 'reviewed', 'authorised'].includes(previousState);
453
- }
454
- return sourceClass === 'analyst';
455
- }
456
- function normalizeActionEvent(raw, actionId, options = {}) {
457
- const item = record(raw);
458
- const previousState = item.previousState === null
459
- ? null
460
- : typeof item.previousState === 'string' && ACTION_STATES.has(item.previousState)
461
- ? item.previousState
462
- : undefined;
463
- const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
464
- ? item.nextState
465
- : null;
466
- const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
467
- ? item.sourceClass
468
- : null;
469
- const occurredAt = optionalIso(item.occurredAt, options);
470
- if (previousState === undefined || !nextState || !sourceClass || !occurredAt
471
- || !isLegalCaseActionTransition(previousState, nextState, sourceClass)) {
472
- return null;
473
- }
474
- const migrationSnapshot = sourceClass === 'migration' && previousState === null;
475
- if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)
476
- && sourceClass !== 'analyst' && !migrationSnapshot)
477
- return null;
478
- const provenance = text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || `${sourceClass}_record`;
479
- const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
480
- ? item.providerOutcome
481
- : null;
482
- if (providerOutcome && !['submitted', 'acknowledged', 'terminal'].includes(nextState) && sourceClass !== 'migration') {
483
- return null;
484
- }
485
- if (providerOutcome && previousState === 'authorised' && nextState === 'submitted' && !migrationSnapshot) {
486
- return null;
487
- }
488
- if (providerOutcome === 'no_response' && sourceClass === 'provider')
489
- return null;
490
- if (nextState === 'terminal' && previousState !== null
491
- && ['drafting', 'ready_for_review', 'reviewed', 'authorised'].includes(previousState)
492
- && (sourceClass !== 'analyst' || providerOutcome !== 'withdrawn')) {
493
- return null;
494
- }
495
- if (providerOutcome === 'withdrawn'
496
- && (nextState !== 'terminal' || (sourceClass !== 'analyst' && !migrationSnapshot)))
497
- return null;
498
- const candidateEvidencePinId = typeof item.evidencePinId === 'string' && SAFE_ID_RE.test(item.evidencePinId)
499
- ? item.evidencePinId
500
- : null;
501
- const evidencePinId = candidateEvidencePinId
502
- && (!options.validEvidencePinIds || options.validEvidencePinIds.has(candidateEvidencePinId))
503
- ? candidateEvidencePinId
504
- : null;
505
- const eventMaterial = {
506
- previousState,
507
- nextState,
508
- occurredAt,
509
- sourceClass,
510
- provenance,
511
- reference: text(item.reference, MAX_RESPONSE_REFERENCE_LENGTH) || null,
512
- evidencePinId,
513
- limitations: limitations([
514
- ...(item.evidencePinId != null && !evidencePinId ? ['A malformed or dangling evidence-pin reference was omitted from this transition.'] : []),
515
- ...limitations(item.limitations),
516
- ]),
517
- providerOutcome,
518
- outcomeDetail: text(item.outcomeDetail, MAX_RESPONSE_RATIONALE_LENGTH) || null,
519
- originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
520
- ? item.originActionId
521
- : null,
522
- };
523
- return {
524
- id: safeId(item.id, 'action-event', { actionId, ...eventMaterial }),
525
- ...eventMaterial,
526
- applied: false,
527
- };
528
- }
529
- function legacyActionState(value) {
530
- if (typeof value === 'string' && ACTION_STATES.has(value))
531
- return value;
532
- if (typeof value === 'string' && LEGACY_ACTION_STATE_MAP[value])
533
- return LEGACY_ACTION_STATE_MAP[value];
534
- return 'drafting';
535
- }
536
- function legacyActionEvent(item, actionId, createdAt, fallback, options) {
537
- const nextState = legacyActionState(item.state);
538
- const occurredAt = iso(item.updatedAt, createdAt || fallback, options);
539
- const sourceVersion = options.sourceVersion;
540
- const provenance = sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12
541
- ? 'case_v12_legacy_snapshot'
542
- : 'legacy_action_snapshot';
543
- const legacyState = typeof item.state === 'string' ? item.state : 'planned';
544
- const migrationLimitation = sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12
545
- ? `Migrated from the Case v12 current state "${text(legacyState, 40)}"; pre-v13 transition history is unavailable.`
546
- : 'Recovered a legacy current-state action; earlier transition history is unavailable.';
547
- const eventMaterial = {
548
- previousState: null,
549
- nextState,
550
- occurredAt,
551
- sourceClass: 'migration',
552
- provenance,
553
- reference: text(item.reference, MAX_RESPONSE_REFERENCE_LENGTH) || null,
554
- evidencePinId: null,
555
- limitations: limitations([migrationLimitation, ...limitations(item.contactLimitations)]),
556
- providerOutcome: !(sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12)
557
- && typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
558
- ? item.providerOutcome
559
- : null,
560
- outcomeDetail: text(item.outcome, MAX_RESPONSE_RATIONALE_LENGTH) || null,
561
- originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
562
- ? item.originActionId
563
- : null,
564
- };
565
- return {
566
- id: deterministicId('action-event', { actionId, ...eventMaterial }),
567
- ...eventMaterial,
568
- applied: true,
569
- };
570
- }
571
- function currentActionRecoveryEvent(actionId, createdAt) {
572
- const eventMaterial = {
573
- previousState: null,
574
- nextState: 'drafting',
575
- occurredAt: createdAt,
576
- sourceClass: 'browser_local',
577
- provenance: 'case_v13_history_recovery',
578
- reference: null,
579
- evidencePinId: null,
580
- limitations: ['The v13 action had no valid transition history. Mutable state, reference, and outcome projections were ignored.'],
581
- providerOutcome: null,
582
- outcomeDetail: null,
583
- originActionId: null,
584
- };
585
- return {
586
- id: deterministicId('action-event', { actionId, ...eventMaterial }),
587
- ...eventMaterial,
588
- applied: true,
589
- };
590
- }
591
- function actionEventContent(event) {
592
- const { applied: _applied, ...material } = event;
593
- return JSON.stringify(material);
594
- }
595
- function projectActionHistory(source, omitted) {
596
- let state = null;
597
- let conflicts = 0;
598
- const history = source.map((event, index) => {
599
- if (state === null && omitted > 0 && index === 0 && event.previousState !== null)
600
- state = event.previousState;
601
- const applied = event.previousState === state
602
- && isLegalCaseActionTransition(event.previousState, event.nextState, event.sourceClass);
603
- if (applied)
604
- state = event.nextState;
605
- else
606
- conflicts += 1;
607
- return { ...event, applied };
608
- });
609
- return { history, state: state ?? 'drafting', conflicts };
610
- }
611
- function normalizeActionHistory(raw, item, actionId, createdAt, fallback, options) {
612
- const source = Array.isArray(raw) ? raw : [];
613
- let omitted = boundedCounter(item.historyOmitted);
614
- let invalid = 0;
615
- let duplicateConflict = 0;
616
- const byId = new Map();
617
- for (const candidate of source.slice(0, MAX_CASE_ACTION_EVENTS_PER_ACTION * 4)) {
618
- const event = normalizeActionEvent(candidate, actionId, options);
619
- if (!event) {
620
- invalid += 1;
621
- continue;
622
- }
623
- const existing = byId.get(event.id);
624
- if (!existing)
625
- byId.set(event.id, event);
626
- else if (actionEventContent(existing) !== actionEventContent(event)) {
627
- duplicateConflict += 1;
628
- if (actionEventContent(event) < actionEventContent(existing))
629
- byId.set(event.id, event);
630
- }
631
- }
632
- invalid += Math.max(0, source.length - MAX_CASE_ACTION_EVENTS_PER_ACTION * 4);
633
- if (!byId.size) {
634
- const recovered = options.sourceVersion != null && options.sourceVersion <= 12
635
- ? legacyActionEvent(item, actionId, createdAt, fallback, options)
636
- : currentActionRecoveryEvent(actionId, createdAt);
637
- byId.set(recovered.id, recovered);
638
- }
639
- let history = [...byId.values()].sort((left, right) => Date.parse(left.occurredAt) - Date.parse(right.occurredAt) || compareCodeUnits(left.id, right.id));
640
- if (history.length > MAX_CASE_ACTION_EVENTS_PER_ACTION) {
641
- omitted += history.length - MAX_CASE_ACTION_EVENTS_PER_ACTION;
642
- history = history.slice(-MAX_CASE_ACTION_EVENTS_PER_ACTION);
643
- }
644
- while (history.length > 1 && bytes(history) > MAX_CASE_ACTION_BYTES) {
645
- history.shift();
646
- omitted += 1;
647
- }
648
- const totalOmitted = omitted + invalid + duplicateConflict;
649
- const projected = projectActionHistory(history, totalOmitted);
650
- const retainedLimitations = limitations(item.historyLimitations).filter((item) => !/^\d+ earlier action transition events? omitted by bounded retention\.$/u.test(item));
651
- const historyLimitations = lifecycleLimitations([
652
- ...(totalOmitted ? [`${totalOmitted} earlier action transition event${totalOmitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
653
- ...(invalid ? [`${invalid} malformed or illegal action transition event${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
654
- ...(duplicateConflict ? [`${duplicateConflict} conflicting duplicate event identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
655
- ...(projected.conflicts ? [`${projected.conflicts} retained concurrent transition${projected.conflicts === 1 ? ' is' : 's are'} not applied to the current-state projection.`] : []),
656
- ...retainedLimitations,
657
- ]);
658
- return {
659
- history: projected.history,
660
- state: projected.state,
661
- omitted: totalOmitted,
662
- historyLimitations,
663
- };
664
- }
665
- function normalizeAction(raw, fallback, options = {}) {
666
- const item = record(raw);
667
- const recipient = text(item.recipient, MAX_RESPONSE_RECIPIENT_LENGTH);
668
- if (!recipient)
669
- return null;
670
- const createdAt = iso(item.createdAt, fallback, options);
671
- const actionId = safeId(item.id, 'action', { recipient, createdAt });
672
- const history = normalizeActionHistory(item.history, item, actionId, createdAt, fallback, options);
673
- const applied = history.history.filter((event) => event.applied);
674
- const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
675
- const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
676
- const latestOutcomeDetail = [...applied].reverse().find((event) => event.outcomeDetail) ?? null;
677
- const latestEventAt = applied.at(-1)?.occurredAt ?? createdAt;
678
- const metadataUpdatedAt = iso(item.metadataUpdatedAt ?? item.updatedAt, createdAt, options);
679
- const updatedAt = Date.parse(latestEventAt) > Date.parse(metadataUpdatedAt) ? latestEventAt : metadataUpdatedAt;
680
- const contactSource = text(item.contactSource, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_supplied';
681
- const legacyPlatformReview = options.sourceVersion != null && options.sourceVersion < 15
682
- && item.type === 'security_contact_report'
683
- ? /^Official .+, reviewed (\d{4}-\d{2}-\d{2})$/u.exec(contactSource)
684
- : null;
685
- return {
686
- id: actionId,
687
- type: legacyPlatformReview
688
- ? 'platform_report'
689
- : typeof item.type === 'string' && ACTION_TYPES.has(item.type)
690
- ? item.type
691
- : 'internal_review',
692
- recipient,
693
- contactSource,
694
- routeObservedAt: legacyPlatformReview
695
- ? normalizeExplicitIsoTimestamp(`${legacyPlatformReview[1]}T00:00:00.000Z`)
696
- : options.sourceVersion != null && options.sourceVersion < 14
697
- ? null
698
- : optionalIso(item.routeObservedAt, options),
699
- routeReviewAfter: options.sourceVersion != null && options.sourceVersion < 15
700
- ? null
701
- : optionalIso(item.routeReviewAfter, options),
702
- contactLimitations: limitations(item.contactLimitations),
703
- dueAt: optionalIso(item.dueAt, options),
704
- state: history.state,
705
- reference: latestReference,
706
- followUpAt: optionalIso(item.followUpAt, options),
707
- providerOutcome: latestProviderOutcome?.providerOutcome ?? null,
708
- outcome: latestProviderOutcome?.outcomeDetail ?? latestOutcomeDetail?.outcomeDetail ?? null,
709
- originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
710
- ? item.originActionId
711
- : null,
712
- history: history.history,
713
- historyOmitted: history.omitted,
714
- historyLimitations: history.historyLimitations,
715
- createdAt,
716
- metadataUpdatedAt,
717
- updatedAt,
718
- };
719
- }
720
- function mergeNormalizedActions(left, right, fallback) {
721
- const metadataWinner = Date.parse(left.metadataUpdatedAt) > Date.parse(right.metadataUpdatedAt)
722
- ? left
723
- : Date.parse(right.metadataUpdatedAt) > Date.parse(left.metadataUpdatedAt)
724
- ? right
725
- : JSON.stringify(left) <= JSON.stringify(right) ? left : right;
726
- return normalizeAction({
727
- ...metadataWinner,
728
- id: left.id,
729
- createdAt: Date.parse(left.createdAt) <= Date.parse(right.createdAt) ? left.createdAt : right.createdAt,
730
- history: [...left.history, ...right.history],
731
- historyOmitted: Math.max(left.historyOmitted, right.historyOmitted),
732
- historyLimitations: [...left.historyLimitations, ...right.historyLimitations],
733
- }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
734
- }
735
- function boundActionCollection(actions, fallback) {
736
- const keep = new Set();
737
- for (const action of actions) {
738
- const latest = action.history.at(-1);
739
- if (latest)
740
- keep.add(`${action.id}\u0000${latest.id}`);
741
- }
742
- const all = actions.flatMap((action) => action.history.map((event) => ({ action, event })))
743
- .sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt)
744
- || compareCodeUnits(right.event.id, left.event.id));
745
- for (const item of all) {
746
- if (keep.size >= MAX_CASE_ACTION_EVENTS_PER_CASE)
747
- break;
748
- keep.add(`${item.action.id}\u0000${item.event.id}`);
749
- }
750
- let bounded = actions.map((action) => {
751
- const history = action.history.filter((event) => keep.has(`${action.id}\u0000${event.id}`));
752
- const omitted = action.historyOmitted + action.history.length - history.length;
753
- return normalizeAction({ ...action, history, historyOmitted: omitted }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
754
- });
755
- while (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE) {
756
- const candidate = bounded
757
- .flatMap((action) => action.history.slice(0, -1).map((event) => ({ action, event })))
758
- .sort((left, right) => Date.parse(left.event.occurredAt) - Date.parse(right.event.occurredAt)
759
- || compareCodeUnits(left.event.id, right.event.id))[0];
760
- if (!candidate)
761
- break;
762
- bounded = bounded.map((action) => action.id !== candidate.action.id
763
- ? action
764
- : normalizeAction({
765
- ...action,
766
- history: action.history.filter((event) => event.id !== candidate.event.id),
767
- historyOmitted: action.historyOmitted + 1,
768
- }, fallback, { sourceVersion: CASE_SCHEMA_VERSION }));
769
- }
770
- if (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE && bounded.length > 1) {
771
- const byteOmissionPattern = /^(\d+) earlier response actions? omitted by the per-Case response-history byte bound\.$/u;
772
- const previouslyOmitted = bounded.reduce((maximum, action) => {
773
- const retainedCount = action.historyLimitations.reduce((count, item) => {
774
- const match = byteOmissionPattern.exec(item);
775
- return match ? Math.max(count, Number(match[1])) : count;
776
- }, 0);
777
- return Math.max(maximum, retainedCount);
778
- }, 0);
779
- bounded = bounded.map((action) => ({
780
- ...action,
781
- historyLimitations: action.historyLimitations.filter((item) => !byteOmissionPattern.test(item)),
782
- }));
783
- let omittedActions = 0;
784
- while (bounded.length > 1) {
785
- bounded = bounded.slice(1);
786
- omittedActions += 1;
787
- const first = bounded[0];
788
- const totalOmitted = previouslyOmitted + omittedActions;
789
- bounded = [{
790
- ...first,
791
- historyLimitations: lifecycleLimitations([
792
- `${totalOmitted} earlier response action${totalOmitted === 1 ? '' : 's'} omitted by the per-Case response-history byte bound.`,
793
- ...first.historyLimitations,
794
- ]),
795
- }, ...bounded.slice(1)];
796
- if (bytes(bounded) <= MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE)
797
- return bounded;
798
- }
799
- }
800
- return bounded;
801
- }
802
- export function normalizeCaseActions(raw, fallback, options = {}) {
803
- if (!Array.isArray(raw))
804
- return [];
805
- const byId = new Map();
806
- const inspected = raw.slice(0, MAX_CASE_ACTIONS * 2);
807
- let invalid = 0;
808
- for (const item of inspected) {
809
- const normalized = normalizeAction(item, fallback, options);
810
- if (!normalized) {
811
- invalid += 1;
812
- continue;
813
- }
814
- const existing = byId.get(normalized.id);
815
- byId.set(normalized.id, existing ? mergeNormalizedActions(existing, normalized, fallback) : normalized);
816
- }
817
- const normalizedActions = [...byId.values()]
818
- .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id));
819
- const uninspected = Math.max(0, raw.length - inspected.length);
820
- const omitted = Math.max(0, normalizedActions.length - MAX_CASE_ACTIONS);
821
- const actions = normalizedActions.slice(-MAX_CASE_ACTIONS);
822
- if (actions.length && (invalid || uninspected || omitted)) {
823
- const first = actions[0];
824
- actions[0] = {
825
- ...first,
826
- historyLimitations: lifecycleLimitations([
827
- ...(invalid ? [`${invalid} malformed response-action candidate${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
828
- ...(uninspected ? [`${uninspected} response-action candidate${uninspected === 1 ? '' : 's'} beyond the bounded inspection window were not traversed.`] : []),
829
- ...(omitted ? [`${omitted} earlier response action${omitted === 1 ? '' : 's'} omitted by the per-Case action bound.`] : []),
830
- ...first.historyLimitations,
831
- ]),
832
- };
833
- }
834
- const actionIds = new Set(actions.map((action) => action.id));
835
- const linked = actions.map((action) => {
836
- const originActionId = action.originActionId && actionIds.has(action.originActionId) && action.originActionId !== action.id
837
- ? action.originActionId
838
- : null;
839
- const invalidOrigins = action.history.filter((event) => event.originActionId
840
- && (!actionIds.has(event.originActionId) || event.originActionId === action.id)).length;
841
- return {
842
- ...action,
843
- originActionId,
844
- history: action.history.map((event) => ({
845
- ...event,
846
- originActionId: event.originActionId && actionIds.has(event.originActionId) && event.originActionId !== action.id
847
- ? event.originActionId
848
- : null,
849
- })),
850
- historyLimitations: lifecycleLimitations([
851
- ...action.historyLimitations,
852
- ...(invalidOrigins || (action.originActionId && !originActionId)
853
- ? ['One or more dangling or self-referential origin-action links were omitted.']
854
- : []),
855
- ]),
856
- };
857
- });
858
- return boundActionCollection(linked, fallback);
859
- }
860
- export function appendCaseAction(current, raw, now) {
861
- if (current.length >= MAX_CASE_ACTIONS) {
862
- throw new Error(`A Case can retain at most ${MAX_CASE_ACTIONS} response actions. No additional action was retained.`);
863
- }
864
- const item = record(raw);
865
- const id = freshId('action');
866
- const originActionId = typeof item.originActionId === 'string' && current.some((action) => action.id === item.originActionId)
867
- ? item.originActionId
868
- : null;
869
- if (item.originActionId != null && !originActionId)
870
- throw new Error('A follow-on action requires an existing originating action.');
871
- const history = [{
872
- id: freshId('action-event'),
873
- previousState: null,
874
- nextState: 'drafting',
875
- occurredAt: now,
876
- sourceClass: 'analyst',
877
- provenance: 'browser_local_action_creation',
878
- reference: null,
879
- evidencePinId: null,
880
- limitations: [],
881
- providerOutcome: null,
882
- outcomeDetail: null,
883
- originActionId,
884
- }];
885
- const created = normalizeAction({
886
- ...item,
887
- id,
888
- originActionId,
889
- history,
890
- historyOmitted: 0,
891
- createdAt: now,
892
- metadataUpdatedAt: now,
893
- }, now, { sourceVersion: CASE_SCHEMA_VERSION });
894
- if (!created)
895
- throw new Error('An action requires a recipient or internal owner.');
896
- return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
897
- }
898
- export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
899
- const action = current.find((item) => item.id === actionId);
900
- if (!action)
901
- throw new Error('That case action no longer exists.');
902
- const item = record(raw);
903
- const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
904
- ? item.nextState
905
- : null;
906
- const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
907
- ? item.sourceClass
908
- : 'analyst';
909
- if (!nextState || !isLegalCaseActionTransition(action.state, nextState, sourceClass)) {
910
- throw new Error(`The transition from ${action.state.replaceAll('_', ' ')} to ${String(item.nextState || 'that state').replaceAll('_', ' ')} is not permitted.`);
911
- }
912
- if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState) && sourceClass !== 'analyst') {
913
- throw new Error('Readiness, review, authorisation, and submission require an explicit analyst transition.');
914
- }
915
- const originActionId = typeof item.originActionId === 'string'
916
- && item.originActionId !== action.id
917
- && current.some((candidate) => candidate.id === item.originActionId)
918
- ? item.originActionId
919
- : action.originActionId;
920
- if (item.originActionId != null && originActionId !== item.originActionId) {
921
- throw new Error('A referral or follow-on transition requires a distinct existing originating action.');
922
- }
923
- if (item.evidencePinId != null && (typeof item.evidencePinId !== 'string'
924
- || !SAFE_ID_RE.test(item.evidencePinId) || (validPinIds && !validPinIds.has(item.evidencePinId)))) {
925
- throw new Error('An action transition evidence pin must reference a retained Case evidence pin.');
926
- }
927
- const occurredAt = optionalIso(item.occurredAt) ?? now;
928
- const event = normalizeActionEvent({
929
- ...item,
930
- id: freshId('action-event'),
931
- previousState: action.state,
932
- nextState,
933
- occurredAt,
934
- sourceClass,
935
- provenance: text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || 'browser_local_explicit_transition',
936
- originActionId,
937
- }, action.id, currentActionNormalizationOptions(validPinIds));
938
- if (!event)
939
- throw new Error('The action transition contains an invalid time, provider outcome, or provenance field.');
940
- const updated = normalizeAction({
941
- ...action,
942
- history: [...action.history, event],
943
- historyOmitted: action.historyOmitted,
944
- }, now, currentActionNormalizationOptions(validPinIds));
945
- return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
946
- }
947
- export function updateCaseAction(current, raw, now, validPinIds) {
948
- const patch = record(raw);
949
- const id = typeof patch.id === 'string' && SAFE_ID_RE.test(patch.id) ? patch.id : '';
950
- const existing = current.find((item) => item.id === id);
951
- if (!existing)
952
- throw new Error('That case action no longer exists.');
953
- if (Object.hasOwn(patch, 'state') || Object.hasOwn(patch, 'outcome') || Object.hasOwn(patch, 'providerOutcome') || Object.hasOwn(patch, 'reference')) {
954
- throw new Error('State, reference, and provider outcomes must be recorded as an append-only action transition.');
955
- }
956
- const metadata = {
957
- ...existing,
958
- type: Object.hasOwn(patch, 'type') ? patch.type : existing.type,
959
- recipient: Object.hasOwn(patch, 'recipient') ? patch.recipient : existing.recipient,
960
- contactSource: Object.hasOwn(patch, 'contactSource') ? patch.contactSource : existing.contactSource,
961
- routeObservedAt: Object.hasOwn(patch, 'routeObservedAt') ? patch.routeObservedAt : existing.routeObservedAt,
962
- contactLimitations: Object.hasOwn(patch, 'contactLimitations') ? patch.contactLimitations : existing.contactLimitations,
963
- dueAt: Object.hasOwn(patch, 'dueAt') ? patch.dueAt : existing.dueAt,
964
- followUpAt: Object.hasOwn(patch, 'followUpAt') ? patch.followUpAt : existing.followUpAt,
965
- originActionId: Object.hasOwn(patch, 'originActionId') ? patch.originActionId : existing.originActionId,
966
- metadataUpdatedAt: now,
967
- };
968
- if (Object.hasOwn(patch, 'originActionId') && patch.originActionId !== null
969
- && (typeof patch.originActionId !== 'string' || patch.originActionId === id || !current.some((item) => item.id === patch.originActionId))) {
970
- throw new Error('A follow-on action requires a distinct existing originating action.');
971
- }
972
- let updated = normalizeAction(metadata, now, currentActionNormalizationOptions(validPinIds));
973
- if (!updated)
974
- throw new Error('An action requires a recipient or internal owner.');
975
- const materialChanged = ['type', 'recipient', 'contactSource', 'routeObservedAt', 'contactLimitations', 'originActionId']
976
- .some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
977
- if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
978
- throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
979
- }
980
- if (materialChanged && ['ready_for_review', 'reviewed', 'authorised'].includes(existing.state)) {
981
- const invalidated = normalizeAction({
982
- ...updated,
983
- history: [...updated.history, {
984
- id: freshId('action-event'),
985
- previousState: existing.state,
986
- nextState: 'drafting',
987
- occurredAt: now,
988
- sourceClass: 'browser_local',
989
- provenance: 'material_action_change',
990
- reference: null,
991
- evidencePinId: null,
992
- limitations: ['Material action inputs changed after review; prior readiness, review, or authorisation no longer applies.'],
993
- providerOutcome: null,
994
- outcomeDetail: null,
995
- originActionId: updated.originActionId,
996
- }],
997
- }, now, currentActionNormalizationOptions(validPinIds));
998
- if (!invalidated)
999
- throw new Error('The material action change could not be retained safely.');
1000
- updated = invalidated;
1001
- }
1002
- const interim = current.map((item) => item.id === id ? updated : item);
1003
- return patch.transition !== undefined
1004
- ? appendCaseActionTransition(interim, id, patch.transition, now, validPinIds)
1005
- : normalizeCaseActions(interim, now, currentActionNormalizationOptions(validPinIds));
1006
- }
1007
- export function buildCaseActionOutcomeSummary(actions, nowRaw = new Date().toISOString()) {
1008
- const now = optionalIso(nowRaw) ?? new Date().toISOString();
1009
- const nowTime = Date.parse(now);
1010
- const active = actions.filter((item) => item.state !== 'terminal');
1011
- const latestOutcomes = actions
1012
- .flatMap((action) => action.history.filter((event) => event.applied && event.providerOutcome).map((event) => ({ action, event })))
1013
- .sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt) || compareCodeUnits(right.event.id, left.event.id))
1014
- .slice(0, 5)
1015
- .map(({ action, event }) => ({
1016
- actionId: action.id,
1017
- recipient: action.recipient,
1018
- state: action.state,
1019
- providerOutcome: event.providerOutcome,
1020
- outcomeDetail: event.outcomeDetail,
1021
- occurredAt: event.occurredAt,
1022
- }));
1023
- return {
1024
- total: actions.length,
1025
- active: active.length,
1026
- drafting: actions.filter((item) => item.state === 'drafting').length,
1027
- readyForReview: actions.filter((item) => item.state === 'ready_for_review').length,
1028
- reviewed: actions.filter((item) => item.state === 'reviewed').length,
1029
- authorised: actions.filter((item) => item.state === 'authorised').length,
1030
- submitted: actions.filter((item) => item.state === 'submitted').length,
1031
- acknowledged: actions.filter((item) => item.state === 'acknowledged').length,
1032
- terminal: actions.filter((item) => item.state === 'terminal').length,
1033
- overdue: active.filter((item) => item.dueAt && Date.parse(item.dueAt) < nowTime).length,
1034
- followUpDue: active.filter((item) => item.followUpAt && Date.parse(item.followUpAt) <= nowTime).length,
1035
- withProviderOutcome: actions.filter((item) => item.providerOutcome !== null).length,
1036
- latestOutcomes,
1037
- };
1038
- }
1039
253
  export function mergeCaseEvidencePins(local, imported, fallback) {
1040
254
  return normalizeCaseEvidencePins([...local, ...imported], fallback);
1041
255
  }
1042
256
  export function mergeCaseDecisions(local, imported, fallback, validPinIds) {
1043
257
  return normalizeCaseDecisions([...local, ...imported], fallback, validPinIds);
1044
258
  }
1045
- export function mergeCaseActions(local, imported, fallback, validPinIds) {
1046
- return normalizeCaseActions([...local, ...imported], fallback, currentActionNormalizationOptions(validPinIds));
1047
- }
1048
259
  function assertionProvenanceList(value, maximum) {
1049
260
  if (!Array.isArray(value))
1050
261
  return [];
@@ -1105,6 +316,10 @@ function normalizeAssertion(raw, fallback, validPinIds, options = {}) {
1105
316
  const provenance = normalizeAssertionProvenance(item.provenance, options);
1106
317
  const legacyIds = uniqueIds(item.evidencePinIds, validPinIds);
1107
318
  const evidenceRelations = normalizeEvidenceRelations(item.evidenceRelations, legacyIds, validPinIds);
319
+ const recheck = readCaseRecheckContext(item.recheck, options.sourceVersion);
320
+ if (recheck && item.kind !== 'next_step') {
321
+ throw new TypeError('A recheck question must remain a next step.');
322
+ }
1108
323
  return {
1109
324
  id: safeId(item.id, 'assertion', { statement, createdAt }),
1110
325
  kind: typeof item.kind === 'string' && ASSERTION_KINDS.has(item.kind)
@@ -1120,6 +335,7 @@ function normalizeAssertion(raw, fallback, validPinIds, options = {}) {
1120
335
  createdAt,
1121
336
  updatedAt: iso(item.updatedAt, createdAt, options),
1122
337
  ...(provenance ? { provenance } : {}),
338
+ ...(recheck ? { recheck } : {}),
1123
339
  };
1124
340
  }
1125
341
  export function normalizeCaseAssertions(raw, fallback, validPinIds, options = {}) {
@@ -1160,6 +376,8 @@ export function updateCaseAssertion(current, raw, now, validPinIds) {
1160
376
  const existing = current.find((item) => item.id === id);
1161
377
  if (!existing)
1162
378
  throw new Error('That analyst assertion no longer exists.');
379
+ if (patch.expectedUpdatedAt !== undefined && patch.expectedUpdatedAt !== existing.updatedAt)
380
+ throw new Error('The analyst assertion changed in another tab. Refresh before updating it.');
1163
381
  const updated = normalizeAssertion({
1164
382
  ...existing,
1165
383
  ...patch,
@@ -1245,7 +463,7 @@ function normalizeCaseSighting(raw, fallback, validPinIds, options = {}) {
1245
463
  ? item.category
1246
464
  : 'other',
1247
465
  source,
1248
- observedAt: iso(item.observedAt, createdAt, options),
466
+ observedAt: optionalIso(item.observedAt, options),
1249
467
  completeness: typeof item.completeness === 'string' && COMPLETENESS.has(item.completeness)
1250
468
  ? item.completeness
1251
469
  : 'unknown',
@@ -1264,7 +482,7 @@ export function normalizeCaseSightings(raw, fallback, validPinIds, options = {})
1264
482
  byId.set(normalized.id, normalized);
1265
483
  }
1266
484
  return [...byId.values()]
1267
- .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt) || compareCodeUnits(left.id, right.id))
485
+ .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id))
1268
486
  .slice(-MAX_CASE_SIGHTINGS);
1269
487
  }
1270
488
  export function appendCaseSighting(current, raw, now, validPinIds) {
@@ -1284,312 +502,6 @@ export function appendCaseSighting(current, raw, now, validPinIds) {
1284
502
  export function mergeCaseSightings(local, imported, fallback, validPinIds) {
1285
503
  return normalizeCaseSightings([...local, ...imported], fallback, validPinIds);
1286
504
  }
1287
- function normalizeObservedEffectReview(raw, fallback, validPinIds, validSightingIds, options = {}) {
1288
- const item = record(raw);
1289
- if (typeof item.state !== 'string' || !OBSERVED_EFFECT_STATES.has(item.state))
1290
- return null;
1291
- if (typeof item.sourceClass !== 'string' || !OBSERVED_EFFECT_SOURCE_CLASSES.has(item.sourceClass))
1292
- return null;
1293
- const source = text(item.source, MAX_RESPONSE_LABEL_LENGTH);
1294
- const observedAt = optionalIso(item.observedAt, options);
1295
- if (!source || !observedAt)
1296
- return null;
1297
- const createdAt = iso(item.createdAt, observedAt || fallback, options);
1298
- const evidencePinId = typeof item.evidencePinId === 'string'
1299
- && SAFE_ID_RE.test(item.evidencePinId)
1300
- && (!validPinIds || validPinIds.has(item.evidencePinId))
1301
- ? item.evidencePinId
1302
- : null;
1303
- const sightingId = typeof item.sightingId === 'string'
1304
- && SAFE_ID_RE.test(item.sightingId)
1305
- && (!validSightingIds || validSightingIds.has(item.sightingId))
1306
- ? item.sightingId
1307
- : null;
1308
- const linkLimitations = [
1309
- ...(item.evidencePinId != null && !evidencePinId ? ['A malformed or dangling evidence-pin reference was omitted from this independent review.'] : []),
1310
- ...(item.sightingId != null && !sightingId ? ['A malformed or dangling sighting reference was omitted from this independent review.'] : []),
1311
- ];
1312
- return {
1313
- id: safeId(item.id, 'effect-review', { state: item.state, source, observedAt, createdAt }),
1314
- state: item.state,
1315
- observedAt,
1316
- sourceClass: item.sourceClass,
1317
- source,
1318
- completeness: typeof item.completeness === 'string' && COMPLETENESS.has(item.completeness)
1319
- ? item.completeness
1320
- : 'unknown',
1321
- limitations: limitations([...linkLimitations, ...limitations(item.limitations)]),
1322
- evidencePinId,
1323
- sightingId,
1324
- followUpAt: optionalIso(item.followUpAt, options),
1325
- createdAt,
1326
- };
1327
- }
1328
- export function normalizeCaseObservedEffectHistory(raw, fallback, validPinIds, validSightingIds, options = {}) {
1329
- const root = record(raw);
1330
- const source = Array.isArray(raw) ? raw : Array.isArray(root.reviews) ? root.reviews : [];
1331
- const byId = new Map();
1332
- let invalid = 0;
1333
- let duplicateConflict = 0;
1334
- for (const item of source.slice(0, MAX_CASE_OBSERVED_EFFECT_REVIEWS * 4)) {
1335
- const normalized = normalizeObservedEffectReview(item, fallback, validPinIds, validSightingIds, options);
1336
- if (!normalized) {
1337
- invalid += 1;
1338
- continue;
1339
- }
1340
- const existing = byId.get(normalized.id);
1341
- if (!existing)
1342
- byId.set(normalized.id, normalized);
1343
- else if (JSON.stringify(normalized) !== JSON.stringify(existing)) {
1344
- duplicateConflict += 1;
1345
- if (JSON.stringify(normalized) < JSON.stringify(existing))
1346
- byId.set(normalized.id, normalized);
1347
- }
1348
- }
1349
- const all = [...byId.values()].sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt) || compareCodeUnits(left.id, right.id));
1350
- invalid += Math.max(0, source.length - MAX_CASE_OBSERVED_EFFECT_REVIEWS * 4);
1351
- const omitted = boundedCounter(root.omitted)
1352
- + invalid
1353
- + duplicateConflict
1354
- + Math.max(0, all.length - MAX_CASE_OBSERVED_EFFECT_REVIEWS);
1355
- const preV13HistoryUnavailable = options.sourceVersion != null && options.sourceVersion <= 12
1356
- ? true
1357
- : root.preV13HistoryUnavailable === true;
1358
- const retainedLimitations = limitations(root.limitations).filter((item) => !/^\d+ earlier observed-effect reviews? omitted by bounded retention\.$/u.test(item));
1359
- return {
1360
- reviews: all.slice(-MAX_CASE_OBSERVED_EFFECT_REVIEWS),
1361
- omitted,
1362
- preV13HistoryUnavailable,
1363
- limitations: lifecycleLimitations([
1364
- ...(omitted ? [`${omitted} earlier observed-effect review${omitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
1365
- ...(invalid ? [`${invalid} malformed or unlinked observed-effect review${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
1366
- ...(duplicateConflict ? [`${duplicateConflict} conflicting observed-effect review identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
1367
- ...(preV13HistoryUnavailable ? ['Migrated from a pre-v13 Case; earlier independent observed-effect review history is unavailable.'] : []),
1368
- 'Observed-effect reviews are independent point-in-time records; provider workflow events do not create or replace them.',
1369
- ...retainedLimitations,
1370
- ]),
1371
- };
1372
- }
1373
- export function appendCaseObservedEffectReview(current, raw, now, validPinIds, validSightingIds) {
1374
- const item = record(raw);
1375
- const created = normalizeObservedEffectReview({ ...item, id: freshId('effect-review'), createdAt: now }, now, validPinIds, validSightingIds);
1376
- if (!created)
1377
- throw new Error('An independent observed-effect review requires a valid state, time, source class, and source.');
1378
- return normalizeCaseObservedEffectHistory({
1379
- reviews: [...current.reviews, created],
1380
- omitted: current.omitted,
1381
- preV13HistoryUnavailable: current.preV13HistoryUnavailable,
1382
- limitations: current.limitations,
1383
- }, now, validPinIds, validSightingIds);
1384
- }
1385
- export function mergeCaseObservedEffectHistories(local, imported, fallback, validPinIds, validSightingIds) {
1386
- return normalizeCaseObservedEffectHistory({
1387
- reviews: [...local.reviews, ...imported.reviews],
1388
- omitted: Math.max(local.omitted, imported.omitted),
1389
- preV13HistoryUnavailable: local.preV13HistoryUnavailable || imported.preV13HistoryUnavailable,
1390
- limitations: [...local.limitations, ...imported.limitations],
1391
- }, fallback, validPinIds, validSightingIds);
1392
- }
1393
- function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options = {}, linkContext = {}) {
1394
- const item = record(raw);
1395
- if (typeof item.reason !== 'string' || !CLOSURE_REASONS.has(item.reason))
1396
- return null;
1397
- const reason = item.reason;
1398
- const summary = text(item.summary, MAX_RESPONSE_RATIONALE_LENGTH);
1399
- if (!summary)
1400
- return null;
1401
- const createdAt = iso(item.createdAt, fallback, options);
1402
- const observedEffectReviewId = typeof item.observedEffectReviewId === 'string'
1403
- && SAFE_ID_RE.test(item.observedEffectReviewId)
1404
- && (!validReviewIds || validReviewIds.has(item.observedEffectReviewId))
1405
- ? item.observedEffectReviewId
1406
- : null;
1407
- const actionId = typeof item.actionId === 'string'
1408
- && SAFE_ID_RE.test(item.actionId)
1409
- && (!validActionIds || validActionIds.has(item.actionId))
1410
- ? item.actionId
1411
- : null;
1412
- const linkedReview = observedEffectReviewId ? linkContext.reviewEvidence?.get(observedEffectReviewId) : undefined;
1413
- const reviewPredatesClosure = linkedReview !== undefined
1414
- && Date.parse(linkedReview.observedAt) <= Date.parse(createdAt)
1415
- && Date.parse(linkedReview.createdAt) <= Date.parse(createdAt);
1416
- if (linkContext.reviewEvidence && reason === 'independently_not_reproduced'
1417
- && (!reviewPredatesClosure || linkedReview?.state !== 'not_reproduced'))
1418
- return null;
1419
- if (linkContext.reviewEvidence && reason === 'infrastructure_changed'
1420
- && (!reviewPredatesClosure || linkedReview?.state !== 'changed'))
1421
- return null;
1422
- const linkedProviderEvents = actionId ? linkContext.providerResolutionEvents?.get(actionId) ?? [] : [];
1423
- if (linkContext.providerResolutionEvents && reason === 'provider_reported_resolution_not_independently_checked'
1424
- && !linkedProviderEvents.some((event) => Date.parse(event.occurredAt) <= Date.parse(createdAt)))
1425
- return null;
1426
- const linkLimitations = [
1427
- ...(item.observedEffectReviewId != null && !observedEffectReviewId ? ['A malformed or dangling observed-effect review reference was omitted from this closure.'] : []),
1428
- ...(item.actionId != null && !actionId ? ['A malformed or dangling response-action reference was omitted from this closure.'] : []),
1429
- ];
1430
- return {
1431
- id: safeId(item.id, 'case-closure', { reason: item.reason, summary, createdAt }),
1432
- reason,
1433
- summary,
1434
- observedEffectReviewId,
1435
- actionId,
1436
- limitations: limitations([...linkLimitations, ...limitations(item.limitations)]),
1437
- createdAt,
1438
- };
1439
- }
1440
- export function buildCaseClosureLinkContext(observedEffects, actions) {
1441
- return {
1442
- reviewEvidence: new Map(observedEffects.reviews.map((review) => [review.id, {
1443
- state: review.state,
1444
- observedAt: review.observedAt,
1445
- createdAt: review.createdAt,
1446
- }])),
1447
- providerResolutionEvents: new Map(actions.map((action) => [action.id, action.history
1448
- .filter((event) => event.applied && event.providerOutcome === 'provider_reports_resolved')
1449
- .map((event) => ({ eventId: event.id, occurredAt: event.occurredAt }))])),
1450
- };
1451
- }
1452
- export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, validActionIds, options = {}, linkContext = {}) {
1453
- const root = record(raw);
1454
- const source = Array.isArray(raw) ? raw : Array.isArray(root.records) ? root.records : [];
1455
- const byId = new Map();
1456
- let invalid = 0;
1457
- let duplicateConflict = 0;
1458
- for (const item of source.slice(0, MAX_CASE_CLOSURES * 4)) {
1459
- const normalized = normalizeClosure(item, fallback, validReviewIds, validActionIds, options, linkContext);
1460
- if (!normalized) {
1461
- invalid += 1;
1462
- continue;
1463
- }
1464
- const existing = byId.get(normalized.id);
1465
- if (!existing)
1466
- byId.set(normalized.id, normalized);
1467
- else if (JSON.stringify(normalized) !== JSON.stringify(existing)) {
1468
- duplicateConflict += 1;
1469
- if (JSON.stringify(normalized) < JSON.stringify(existing))
1470
- byId.set(normalized.id, normalized);
1471
- }
1472
- }
1473
- const all = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id));
1474
- invalid += Math.max(0, source.length - MAX_CASE_CLOSURES * 4);
1475
- const omitted = boundedCounter(root.omitted)
1476
- + invalid
1477
- + duplicateConflict
1478
- + Math.max(0, all.length - MAX_CASE_CLOSURES);
1479
- const preV13HistoryUnavailable = options.sourceVersion != null && options.sourceVersion <= 12
1480
- ? true
1481
- : root.preV13HistoryUnavailable === true;
1482
- const retainedLimitations = limitations(root.limitations).filter((item) => !/^\d+ earlier closure records? omitted by bounded retention\.$/u.test(item));
1483
- return {
1484
- records: all.slice(-MAX_CASE_CLOSURES),
1485
- omitted,
1486
- preV13HistoryUnavailable,
1487
- limitations: lifecycleLimitations([
1488
- ...(omitted ? [`${omitted} earlier closure record${omitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
1489
- ...(invalid ? [`${invalid} malformed, unsupported, or unlinked closure record${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
1490
- ...(duplicateConflict ? [`${duplicateConflict} conflicting closure record identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
1491
- ...(preV13HistoryUnavailable ? ['Migrated from a pre-v13 Case; earlier deliberate closure history is unavailable.'] : []),
1492
- 'Closure records are deliberate analyst actions and do not establish absence, safety, provider performance, or legal sufficiency.',
1493
- ...retainedLimitations,
1494
- ]),
1495
- };
1496
- }
1497
- export function appendCaseClosure(current, raw, now, observedEffects, actions) {
1498
- const item = record(raw);
1499
- const reason = typeof item.reason === 'string' && CLOSURE_REASONS.has(item.reason)
1500
- ? item.reason
1501
- : null;
1502
- const review = typeof item.observedEffectReviewId === 'string'
1503
- ? observedEffects.reviews.find((candidate) => candidate.id === item.observedEffectReviewId) ?? null
1504
- : null;
1505
- const action = typeof item.actionId === 'string'
1506
- ? actions.find((candidate) => candidate.id === item.actionId) ?? null
1507
- : null;
1508
- if (reason === 'independently_not_reproduced'
1509
- && (review?.state !== 'not_reproduced' || Date.parse(review.observedAt) > Date.parse(now) || Date.parse(review.createdAt) > Date.parse(now))) {
1510
- throw new Error('This closure reason requires a linked independent not-reproduced review.');
1511
- }
1512
- if (reason === 'infrastructure_changed'
1513
- && (review?.state !== 'changed' || Date.parse(review.observedAt) > Date.parse(now) || Date.parse(review.createdAt) > Date.parse(now))) {
1514
- throw new Error('This closure reason requires a linked independent changed review.');
1515
- }
1516
- if (reason === 'provider_reported_resolution_not_independently_checked'
1517
- && (action?.providerOutcome !== 'provider_reports_resolved'
1518
- || !action.history.some((event) => event.applied
1519
- && event.providerOutcome === 'provider_reports_resolved'
1520
- && Date.parse(event.occurredAt) <= Date.parse(now)))) {
1521
- throw new Error('This closure reason requires a linked typed provider-reported-resolution outcome.');
1522
- }
1523
- const linkContext = buildCaseClosureLinkContext(observedEffects, actions);
1524
- 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);
1525
- if (!created || !reason)
1526
- throw new Error('A deliberate closure requires a typed reason and summary.');
1527
- return normalizeCaseClosureHistory({
1528
- records: [...current.records, created],
1529
- omitted: current.omitted,
1530
- preV13HistoryUnavailable: current.preV13HistoryUnavailable,
1531
- limitations: current.limitations,
1532
- }, now, new Set(observedEffects.reviews.map((candidate) => candidate.id)), new Set(actions.map((candidate) => candidate.id)), {}, linkContext);
1533
- }
1534
- export function mergeCaseClosureHistories(local, imported, fallback, validReviewIds, validActionIds, linkContext = {}) {
1535
- return normalizeCaseClosureHistory({
1536
- records: [...local.records, ...imported.records],
1537
- omitted: Math.max(local.omitted, imported.omitted),
1538
- preV13HistoryUnavailable: local.preV13HistoryUnavailable || imported.preV13HistoryUnavailable,
1539
- limitations: [...local.limitations, ...imported.limitations],
1540
- }, fallback, validReviewIds, validActionIds, {}, linkContext);
1541
- }
1542
- export function buildCaseResponseLifecycleSummary(input) {
1543
- const providerEvents = (input.actions ?? []).flatMap((action) => action.history
1544
- .filter((event) => event.providerOutcome)
1545
- .map((event) => ({ action, event })));
1546
- providerEvents.sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt)
1547
- || compareCodeUnits(right.event.id, left.event.id));
1548
- const providerTime = providerEvents[0]?.event.occurredAt ?? null;
1549
- const providerEventsAtLatestTime = providerTime === null
1550
- ? []
1551
- : providerEvents.filter(({ event }) => event.occurredAt === providerTime);
1552
- const latestProvider = providerEventsAtLatestTime.length === 1 && providerEventsAtLatestTime[0].event.applied
1553
- ? providerEventsAtLatestTime[0]
1554
- : null;
1555
- const providerOutcomeState = providerEvents.length === 0
1556
- ? 'missing'
1557
- : latestProvider ? 'available' : 'ambiguous';
1558
- const reviews = [...(input.observedEffects?.reviews ?? [])]
1559
- .sort((left, right) => Date.parse(right.observedAt) - Date.parse(left.observedAt) || compareCodeUnits(right.id, left.id));
1560
- const latestObserved = reviews[0] ?? null;
1561
- const changedReviews = reviews.filter((review) => review.state === 'changed');
1562
- const changedTime = changedReviews[0]?.observedAt ?? null;
1563
- const changedAtLatestTime = changedTime === null
1564
- ? []
1565
- : changedReviews.filter((review) => review.observedAt === changedTime);
1566
- const latestObservedChangeAt = changedAtLatestTime.length === 1 ? changedTime : null;
1567
- const observedChangeState = changedReviews.length === 0
1568
- ? 'missing'
1569
- : latestObservedChangeAt ? 'available' : 'ambiguous';
1570
- const latestClosure = [...(input.closures?.records ?? [])]
1571
- .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || compareCodeUnits(right.id, left.id))[0] ?? null;
1572
- return {
1573
- providerOutcomeState,
1574
- latestProviderOutcome: latestProvider ? {
1575
- actionId: latestProvider.action.id,
1576
- eventId: latestProvider.event.id,
1577
- outcome: latestProvider.event.providerOutcome,
1578
- occurredAt: latestProvider.event.occurredAt,
1579
- reference: latestProvider.event.reference,
1580
- } : null,
1581
- observedChangeState,
1582
- latestObservedEffect: latestObserved ? {
1583
- reviewId: latestObserved.id,
1584
- state: latestObserved.state,
1585
- observedAt: latestObserved.observedAt,
1586
- sourceClass: latestObserved.sourceClass,
1587
- source: latestObserved.source,
1588
- } : null,
1589
- latestObservedChangeAt,
1590
- latestClosure,
1591
- };
1592
- }
1593
505
  export function buildCaseInvestigationTrail(input) {
1594
506
  return [
1595
507
  ...(input.assertions ?? []).map((item) => ({
@@ -1624,15 +536,15 @@ export function buildCaseInvestigationTrail(input) {
1624
536
  id: `sighting:${item.id}`,
1625
537
  kind: 'sighting',
1626
538
  label: `${item.state.replaceAll('_', ' ')} · ${item.category}`,
1627
- detail: `${item.source} · ${item.completeness}`,
1628
- createdAt: item.observedAt,
539
+ detail: `${item.source} · ${item.completeness} · observed ${item.observedAt ?? 'time unavailable'}`,
540
+ createdAt: item.createdAt,
1629
541
  })),
1630
542
  ...(input.observedEffects?.reviews ?? []).map((item) => ({
1631
543
  id: `observed-effect:${item.id}`,
1632
544
  kind: 'observed_effect',
1633
545
  label: `independent effect · ${item.state.replaceAll('_', ' ')}`,
1634
- detail: `${item.source} · ${item.completeness}`,
1635
- createdAt: item.observedAt,
546
+ detail: `${item.source} · ${item.completeness} · observed ${item.observedAt}`,
547
+ createdAt: item.createdAt,
1636
548
  })),
1637
549
  ...(input.closures?.records ?? []).map((item) => ({
1638
550
  id: `closure:${item.id}`,